detached 203eb71

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.
8787
8888 In zot, run `/skills`. A picker lists every discovered skill with its
8989 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.
9193
9294 ## Pinning skills
9395
@@ -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 its88 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 full89 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 skills94 ## Pinning skills
93 95
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 +25 -16
@@ -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{} }
@@ -143,35 +144,43 @@ 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
148156 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, "")
152160
153161 // Fold the markdown body to the dialog width. The renderer
154162 // hard-truncates over-wide rows, and skill bodies are prose that
155163 // routinely exceeds one row, so an unfolded body silently loses
156164 // everything past the right edge.
157- bodyLines := renderDialogMarkdownRows(s.Body, th, width)
165+ content = append(content, renderDialogMarkdownRows(s.Body, th, width)...)
158166
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
162173 }
163174 if d.scroll < 0 {
164175 d.scroll = 0
165176 }
166177 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)))
175184 }
176185 out = append(out, frameRule(th, width))
177186 return out
@@ -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{} }
@@ -143,35 +144,43 @@ 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{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 renderer161 // Fold the markdown body to the dialog width. The renderer
154 // hard-truncates over-wide rows, and skill bodies are prose that162 // hard-truncates over-wide rows, and skill bodies are prose that
155 // routinely exceeds one row, so an unfolded body silently loses163 // 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 = 16167+ maxRows := d.maxRows
160- if d.scroll > len(bodyLines)-1 {168+ if maxRows <= 0 || maxRows > 16 {
161- d.scroll = len(bodyLines) - 1169+ 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 = 0175 d.scroll = 0
165 }176 }
166 end := d.scroll + maxRows177 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 the181+ 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 out186 return out
M packages/agent/modes/skills_dialog_test.go +43 -0
@@ -68,6 +68,49 @@ func TestSkillsDialogBodyWrapsLongLines(t *testing.T) {
6868 }
6969 }
7070
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+
71114 func TestSkillsDialogWrapsHintLines(t *testing.T) {
72115 const width = 44
73116 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 = 44115 const width = 44
73 d := newSkillsDialog()116 d := newSkillsDialog()

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