Keep wrapped skill previews reachable on short terminals
- commit
- 203eb71cb2e38c9a94c024752056328eb9e55942
- author
- patriceckhart · · 2026-09-28 04:50 UTC
- parent
- 17f39f2
- signature
- unsigned
4 files · +81 -17
M docs/skills.md +3 -1
| @@ -87,7 +87,9 @@ on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows. | |||
| 87 | 87 | ||
| 88 | 88 | In zot, run `/skills`. A picker lists every discovered skill with its | |
| 89 | 89 | description and source path. Press enter on a row to view the full | |
| 90 | - | body inline. Press esc to go back. | |
| 90 | + | body inline. Long descriptions, source paths, and body lines wrap to fit the | |
| 91 | + | terminal. Use up/down or pgup/pgdn to scroll through the preview, including its | |
| 92 | + | metadata on short terminals. Press esc to go back. | |
| 91 | 93 | ||
| 92 | 94 | ## Pinning skills | |
| 93 | 95 | ||
| @@ -87,7 +87,9 @@ on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows. | |||||
| 87 | 87 | ||||
| 88 | In zot, run `/skills`. A picker lists every discovered skill with its | 88 | In zot, run `/skills`. A picker lists every discovered skill with its | ||
| 89 | description and source path. Press enter on a row to view the full | 89 | description and source path. Press enter on a row to view the full | ||
| 90 | - | body inline. Press esc to go back. | 90 | + | body inline. Long descriptions, source paths, and body lines wrap to fit the |
| 91 | + | terminal. Use up/down or pgup/pgdn to scroll through the preview, including its | |||
| 92 | + | metadata on short terminals. Press esc to go back. | |||
| 91 | 93 | ||||
| 92 | ## Pinning skills | 94 | ## Pinning skills | ||
| 93 | 95 | ||||
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 +25 -16
| @@ -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{} } | |
| @@ -143,35 +144,43 @@ 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 | 156 | 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, "") | |
| 157 | + | content := wrapDialogMutedRows(th, s.Description, width) | |
| 158 | + | content = append(content, wrapDialogMutedRows(th, "source: "+s.Source+" ("+s.Path+")", width)...) | |
| 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 | 163 | // routinely exceeds one row, so an unfolded body silently loses | |
| 156 | 164 | // everything past the right edge. | |
| 157 | - | bodyLines := renderDialogMarkdownRows(s.Body, th, width) | |
| 165 | + | content = append(content, renderDialogMarkdownRows(s.Body, th, width)...) | |
| 158 | 166 | ||
| 159 | - | const maxRows = 16 | |
| 160 | - | if d.scroll > len(bodyLines)-1 { | |
| 161 | - | 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 | |
| 162 | 173 | } | |
| 163 | 174 | if d.scroll < 0 { | |
| 164 | 175 | d.scroll = 0 | |
| 165 | 176 | } | |
| 166 | 177 | end := d.scroll + maxRows | |
| 167 | - | if end > len(bodyLines) { | |
| 168 | - | end = len(bodyLines) | |
| 169 | - | } | |
| 170 | - | // renderDialogMarkdownRows already prefixes each row with the | |
| 171 | - | // dialog's text indent. | |
| 172 | - | out = append(out, bodyLines[d.scroll:end]...) | |
| 173 | - | if end < len(bodyLines) { | |
| 174 | - | out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end))) | |
| 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))) | |
| 175 | 184 | } | |
| 176 | 185 | out = append(out, frameRule(th, width)) | |
| 177 | 186 | return out | |
| @@ -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{} } | ||
| @@ -143,35 +144,43 @@ 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{frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width)} | 156 | out := []string{frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width)} | ||
| 149 | - | out = append(out, wrapDialogMutedRows(th, s.Description, width)...) | 157 | + | content := wrapDialogMutedRows(th, s.Description, width) |
| 150 | - | out = append(out, wrapDialogMutedRows(th, "source: "+s.Source+" ("+s.Path+")", width)...) | 158 | + | content = append(content, wrapDialogMutedRows(th, "source: "+s.Source+" ("+s.Path+")", width)...) |
| 151 | - | out = append(out, "") | 159 | + | content = append(content, "") |
| 152 | 160 | ||||
| 153 | // Fold the markdown body to the dialog width. The renderer | 161 | // Fold the markdown body to the dialog width. The renderer | ||
| 154 | // hard-truncates over-wide rows, and skill bodies are prose that | 162 | // hard-truncates over-wide rows, and skill bodies are prose that | ||
| 155 | // routinely exceeds one row, so an unfolded body silently loses | 163 | // routinely exceeds one row, so an unfolded body silently loses | ||
| 156 | // everything past the right edge. | 164 | // everything past the right edge. | ||
| 157 | - | bodyLines := renderDialogMarkdownRows(s.Body, th, width) | 165 | + | content = append(content, renderDialogMarkdownRows(s.Body, th, width)...) |
| 158 | 166 | ||||
| 159 | - | const maxRows = 16 | 167 | + | maxRows := d.maxRows |
| 160 | - | if d.scroll > len(bodyLines)-1 { | 168 | + | if maxRows <= 0 || maxRows > 16 { |
| 161 | - | d.scroll = len(bodyLines) - 1 | 169 | + | maxRows = 16 |
| 170 | + | } | |||
| 171 | + | if d.scroll > len(content)-1 { | |||
| 172 | + | d.scroll = len(content) - 1 | |||
| 162 | } | 173 | } | ||
| 163 | if d.scroll < 0 { | 174 | if d.scroll < 0 { | ||
| 164 | d.scroll = 0 | 175 | d.scroll = 0 | ||
| 165 | } | 176 | } | ||
| 166 | end := d.scroll + maxRows | 177 | end := d.scroll + maxRows | ||
| 167 | - | if end > len(bodyLines) { | 178 | + | if end > len(content) { |
| 168 | - | end = len(bodyLines) | 179 | + | end = len(content) |
| 169 | - | } | 180 | + | } |
| 170 | - | // renderDialogMarkdownRows already prefixes each row with the | 181 | + | out = append(out, content[d.scroll:end]...) |
| 171 | - | // dialog's text indent. | 182 | + | if end < len(content) { |
| 172 | - | out = append(out, bodyLines[d.scroll:end]...) | 183 | + | out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(content)-end))) |
| 173 | - | if end < len(bodyLines) { | |||
| 174 | - | out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end))) | |||
| 175 | } | 184 | } | ||
| 176 | out = append(out, frameRule(th, width)) | 185 | out = append(out, frameRule(th, width)) | ||
| 177 | return out | 186 | return out | ||
M packages/agent/modes/skills_dialog_test.go +43 -0
| @@ -68,6 +68,49 @@ func TestSkillsDialogBodyWrapsLongLines(t *testing.T) { | |||
| 68 | 68 | } | |
| 69 | 69 | } | |
| 70 | 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 | + | ||
| 71 | 114 | func TestSkillsDialogWrapsHintLines(t *testing.T) { | |
| 72 | 115 | const width = 44 | |
| 73 | 116 | d := newSkillsDialog() | |
| @@ -68,6 +68,49 @@ func TestSkillsDialogBodyWrapsLongLines(t *testing.T) { | |||||
| 68 | } | 68 | } | ||
| 69 | } | 69 | } | ||
| 70 | 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 | + | ||||
| 71 | func TestSkillsDialogWrapsHintLines(t *testing.T) { | 114 | func TestSkillsDialogWrapsHintLines(t *testing.T) { | ||
| 72 | const width = 44 | 115 | const width = 44 | ||
| 73 | d := newSkillsDialog() | 116 | d := newSkillsDialog() | ||