detached 789665f

tui: stable order + "── extensions ──" divider in slash autocomplete

Two related fixes for the popup that opens when you type /:

1) Extension commands no longer jump around between renders.
   SetExtra now sorts the incoming list by name, so map-iteration
   randomness in Manager.Commands() doesn't reshuffle the popup
   on every keystroke.

2) Visual grouping. The popup now inserts a header row between the
   built-in commands and the extension-registered ones:

       /help       show key bindings ...
       ...
       /exit       exit zot
       ── extensions ─────────────────────────
       /hello      say hello (optional name)
       /note       append text to the scratchpad
       ...

   Headers are non-navigable: Up/Down skip across them, the cursor
   never lands on one, and Selection / matches resolve through them.
   pruneOrphanHeaders drops a header when filtering eliminated every
   command underneath it (typing "/he" no longer shows a lone
   "── extensions ──" divider after the matching built-ins).

Removed the now-redundant "(ext: name)" suffix on extension
descriptions; the header makes the source obvious.

slashCommand grew a Header bool; existing two-element struct
literals in slashCatalog converted to named fields.

Tests pass with -race; vet clean on linux/windows/darwin.
commit
789665fb1c281d7b763e56a5b9afc43ee075ab9c
author
patriceckhart · · 2026-04-19 13:34 UTC
parent
744685d
signature
unsigned
2 files · +164 -27
M internal/agent/modes/interactive.go +9 -1
@@ -537,9 +537,17 @@ func (i *Interactive) redraw() {
537537 catalog := i.cfg.Extensions.Commands()
538538 extra := make([]slashCommand, 0, len(catalog))
539539 for _, c := range catalog {
540+ // The popup renders extension commands under a dedicated
541+ // "── extensions ───" divider, so the description doesn't
542+ // need to repeat the source. If the description is empty,
543+ // fall back to the extension name so the row isn't blank.
544+ desc := c.Description
545+ if strings.TrimSpace(desc) == "" {
546+ desc = "(" + c.Extension + ")"
547+ }
540548 extra = append(extra, slashCommand{
541549 Name: "/" + c.Name,
542- Desc: c.Description + " (ext: " + c.Extension + ")",
550+ Desc: desc,
543551 })
544552 }
545553 i.suggest.SetExtra(extra)
@@ -537,9 +537,17 @@ func (i *Interactive) redraw() {
537 catalog := i.cfg.Extensions.Commands()537 catalog := i.cfg.Extensions.Commands()
538 extra := make([]slashCommand, 0, len(catalog))538 extra := make([]slashCommand, 0, len(catalog))
539 for _, c := range catalog {539 for _, c := range catalog {
540+ // The popup renders extension commands under a dedicated
541+ // "── extensions ───" divider, so the description doesn't
542+ // need to repeat the source. If the description is empty,
543+ // fall back to the extension name so the row isn't blank.
544+ desc := c.Description
545+ if strings.TrimSpace(desc) == "" {
546+ desc = "(" + c.Extension + ")"
547+ }
540 extra = append(extra, slashCommand{548 extra = append(extra, slashCommand{
541 Name: "/" + c.Name,549 Name: "/" + c.Name,
542- Desc: c.Description + " (ext: " + c.Extension + ")",550+ Desc: desc,
543 })551 })
544 }552 }
545 i.suggest.SetExtra(extra)553 i.suggest.SetExtra(extra)
M internal/agent/modes/slash_suggest.go +155 -26
@@ -1,15 +1,19 @@
11 package modes
22
33 import (
4+ "sort"
45 "strings"
56
67 "github.com/patriceckhart/zot/internal/tui"
78 )
89
9-// slashCommand is one entry in the autocomplete popup.
10+// slashCommand is one entry in the autocomplete popup. Header rows
11+// (group dividers like "── extensions ───") are real entries
12+// flagged with header=true; they render but aren't navigable.
1013 type slashCommand struct {
11- Name string // with leading "/"
12- Desc string
14+ Name string // with leading "/"
15+ Desc string
16+ Header bool // true = visual divider, not selectable
1317 }
1418
1519 // slashCancelsTurn reports whether the named slash command, when run
@@ -28,19 +32,19 @@ func slashCancelsTurn(head string) bool {
2832 // slashCatalog lists every slash command the interactive mode handles.
2933 // Keep in sync with runSlash().
3034 var slashCatalog = []slashCommand{
31- {"/help", "show key bindings and commands"},
32- {"/login", "log in via api key or subscription"},
33- {"/logout", "clear a provider's credentials"},
34- {"/model", "pick a model (or /model <id>)"},
35- {"/sessions", "resume a previous session for this directory"},
36- {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},
37- {"/btw", "side-chat that doesn't add to the main thread (saves tokens)"},
38- {"/skills", "list discovered skills (SKILL.md files)"},
39- {"/compact", "summarize and replace the transcript to free up context"},
40- {"/lock", "confine tools to the current directory"},
41- {"/unlock", "allow tools to touch paths outside this directory"},
42- {"/clear", "clear the chat transcript"},
43- {"/exit", "exit zot"},
35+ {Name: "/help", Desc: "show key bindings and commands"},
36+ {Name: "/login", Desc: "log in via api key or subscription"},
37+ {Name: "/logout", Desc: "clear a provider's credentials"},
38+ {Name: "/model", Desc: "pick a model (or /model <id>)"},
39+ {Name: "/sessions", Desc: "resume a previous session for this directory"},
40+ {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
41+ {Name: "/btw", Desc: "side-chat that doesn't add to the main thread (saves tokens)"},
42+ {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"},
43+ {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
44+ {Name: "/lock", Desc: "confine tools to the current directory"},
45+ {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"},
46+ {Name: "/clear", Desc: "clear the chat transcript"},
47+ {Name: "/exit", Desc: "exit zot"},
4448 }
4549
4650 // slashSuggester renders the popup that appears when the editor starts
@@ -50,23 +54,38 @@ type slashSuggester struct {
5054
5155 // extra are commands contributed by extensions, refreshed each
5256 // frame from the extension manager. Empty when no extensions
53- // have registered any.
57+ // have registered any. Sorted by name in SetExtra so map
58+ // iteration order doesn't reshuffle the popup between frames.
5459 extra []slashCommand
60+
61+ // lastMatches is the list shown in the most recent Render call.
62+ // Up/Down read it so they know which indexes to skip across
63+ // header rows.
64+ lastMatches []slashCommand
5565 }
5666
5767 // SetExtra updates the extension-contributed command list. Called
5868 // once per render with the live snapshot from the extension manager.
59-func (s *slashSuggester) SetExtra(cmds []slashCommand) { s.extra = cmds }
69+// The list is sorted by name so the popup ordering stays stable
70+// across redraws (Manager.Commands() iterates a map, which Go
71+// randomises).
72+func (s *slashSuggester) SetExtra(cmds []slashCommand) {
73+ sorted := append([]slashCommand(nil), cmds...)
74+ sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
75+ s.extra = sorted
76+}
6077
6178 // allCatalog returns slashCatalog plus the current extra commands
62-// (extension-registered). Extra entries are only kept if they don't
63-// collide with a built-in name; the built-in always wins.
79+// (extension-registered) with a header divider between the two
80+// groups. Extra entries are only kept if they don't collide with
81+// a built-in name; the built-in always wins.
6482 func (s *slashSuggester) allCatalog() []slashCommand {
6583 if len(s.extra) == 0 {
6684 return slashCatalog
6785 }
68- out := make([]slashCommand, 0, len(slashCatalog)+len(s.extra))
86+ out := make([]slashCommand, 0, len(slashCatalog)+len(s.extra)+1)
6987 out = append(out, slashCatalog...)
88+ var kept []slashCommand
7089 for _, c := range s.extra {
7190 dup := false
7291 for _, b := range slashCatalog {
@@ -76,9 +95,13 @@ func (s *slashSuggester) allCatalog() []slashCommand {
7695 }
7796 }
7897 if !dup {
79- out = append(out, c)
98+ kept = append(kept, c)
8099 }
81100 }
101+ if len(kept) > 0 {
102+ out = append(out, slashCommand{Header: true, Name: "extensions"})
103+ out = append(out, kept...)
104+ }
82105 return out
83106 }
84107
@@ -147,14 +170,46 @@ func (s *slashSuggester) matches(input string) []slashCommand {
147170 }
148171 var out []slashCommand
149172 for _, c := range s.allCatalog() {
173+ if c.Header {
174+ // Headers ride along whenever there's at least one
175+ // matching command from their group; we drop trailing
176+ // orphan headers below.
177+ out = append(out, c)
178+ continue
179+ }
150180 if strings.HasPrefix(c.Name, input) {
151181 out = append(out, c)
152182 }
153183 }
184+ return pruneOrphanHeaders(out)
185+}
186+
187+// pruneOrphanHeaders removes header rows that have no commands
188+// after them (i.e. the next non-header is missing or another
189+// header). Keeps the popup clean when the input filters out a whole
190+// group.
191+func pruneOrphanHeaders(in []slashCommand) []slashCommand {
192+ out := make([]slashCommand, 0, len(in))
193+ for i, c := range in {
194+ if c.Header {
195+ nextReal := false
196+ for j := i + 1; j < len(in); j++ {
197+ if !in[j].Header {
198+ nextReal = true
199+ break
200+ }
201+ }
202+ if !nextReal {
203+ continue
204+ }
205+ }
206+ out = append(out, c)
207+ }
154208 return out
155209 }
156210
157-// clampCursor keeps the cursor inside the current match list.
211+// clampCursor keeps the cursor inside the current match list and
212+// nudges it past any header row so navigation never lands on one.
158213 func (s *slashSuggester) clampCursor(n int) {
159214 if n <= 0 {
160215 s.cursor = 0
@@ -168,9 +223,51 @@ func (s *slashSuggester) clampCursor(n int) {
168223 }
169224 }
170225
171-// Up / Down navigate the suggestion list.
172-func (s *slashSuggester) Up() { s.cursor-- }
173-func (s *slashSuggester) Down() { s.cursor++ }
226+// Up / Down navigate the suggestion list, skipping header rows in
227+// either direction so the cursor only ever lands on selectable
228+// commands.
229+func (s *slashSuggester) Up() {
230+ s.skipHeader(-1)
231+}
232+func (s *slashSuggester) Down() {
233+ s.skipHeader(+1)
234+}
235+
236+// skipHeader moves the cursor by step, then keeps moving in the same
237+// direction across header rows until it lands on a real command (or
238+// hits the edge, in which case it bounces back to the nearest real
239+// row).
240+func (s *slashSuggester) skipHeader(step int) {
241+ list := s.lastMatches
242+ n := len(list)
243+ if n == 0 {
244+ return
245+ }
246+ s.cursor += step
247+ for s.cursor >= 0 && s.cursor < n && list[s.cursor].Header {
248+ s.cursor += step
249+ }
250+ if s.cursor < 0 {
251+ // Bounce: find the first non-header from the top.
252+ for i, c := range list {
253+ if !c.Header {
254+ s.cursor = i
255+ return
256+ }
257+ }
258+ s.cursor = 0
259+ }
260+ if s.cursor >= n {
261+ // Bounce: find the last non-header.
262+ for i := n - 1; i >= 0; i-- {
263+ if !list[i].Header {
264+ s.cursor = i
265+ return
266+ }
267+ }
268+ s.cursor = n - 1
269+ }
270+}
174271
175272 // Active reports whether the popup is visible for the given input.
176273 func (s *slashSuggester) Active(input string) bool {
@@ -178,12 +275,25 @@ func (s *slashSuggester) Active(input string) bool {
178275 }
179276
180277 // Selection returns the currently highlighted command for input, or "".
278+// Headers are never returned even if the cursor index would point at
279+// one; the cursor is moved forward to the next real command.
181280 func (s *slashSuggester) Selection(input string) string {
182281 m := s.matches(input)
183282 if len(m) == 0 {
184283 return ""
185284 }
186285 s.clampCursor(len(m))
286+ if m[s.cursor].Header {
287+ for i := s.cursor + 1; i < len(m); i++ {
288+ if !m[i].Header {
289+ s.cursor = i
290+ break
291+ }
292+ }
293+ }
294+ if m[s.cursor].Header {
295+ return ""
296+ }
187297 return m[s.cursor].Name
188298 }
189299
@@ -193,9 +303,28 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
193303 if len(m) == 0 {
194304 return nil
195305 }
306+ s.lastMatches = m
196307 s.clampCursor(len(m))
308+ // Snap cursor off any header (e.g. after a filter change put it on one).
309+ if s.cursor >= 0 && s.cursor < len(m) && m[s.cursor].Header {
310+ for i := s.cursor + 1; i < len(m); i++ {
311+ if !m[i].Header {
312+ s.cursor = i
313+ break
314+ }
315+ }
316+ }
197317 var lines []string
198318 for i, c := range m {
319+ if c.Header {
320+ rule := strings.Repeat("─", width)
321+ label := "── " + c.Name + " "
322+ if len(label) < width {
323+ rule = label + strings.Repeat("─", width-len(label))
324+ }
325+ lines = append(lines, th.FG256(th.Muted, rule))
326+ continue
327+ }
199328 name := c.Name
200329 if len(name) < 10 {
201330 name = name + strings.Repeat(" ", 10-len(name))
@@ -1,15 +1,19 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "sort"
4 "strings"5 "strings"
5 6
6 "github.com/patriceckhart/zot/internal/tui"7 "github.com/patriceckhart/zot/internal/tui"
7 )8 )
8 9
9-// slashCommand is one entry in the autocomplete popup.10+// slashCommand is one entry in the autocomplete popup. Header rows
11+// (group dividers like "── extensions ───") are real entries
12+// flagged with header=true; they render but aren't navigable.
10 type slashCommand struct {13 type slashCommand struct {
11- Name string // with leading "/"14+ Name string // with leading "/"
12- Desc string15+ Desc string
16+ Header bool // true = visual divider, not selectable
13 }17 }
14 18
15 // slashCancelsTurn reports whether the named slash command, when run19 // slashCancelsTurn reports whether the named slash command, when run
@@ -28,19 +32,19 @@ func slashCancelsTurn(head string) bool {
28 // slashCatalog lists every slash command the interactive mode handles.32 // slashCatalog lists every slash command the interactive mode handles.
29 // Keep in sync with runSlash().33 // Keep in sync with runSlash().
30 var slashCatalog = []slashCommand{34 var slashCatalog = []slashCommand{
31- {"/help", "show key bindings and commands"},35+ {Name: "/help", Desc: "show key bindings and commands"},
32- {"/login", "log in via api key or subscription"},36+ {Name: "/login", Desc: "log in via api key or subscription"},
33- {"/logout", "clear a provider's credentials"},37+ {Name: "/logout", Desc: "clear a provider's credentials"},
34- {"/model", "pick a model (or /model <id>)"},38+ {Name: "/model", Desc: "pick a model (or /model <id>)"},
35- {"/sessions", "resume a previous session for this directory"},39+ {Name: "/sessions", Desc: "resume a previous session for this directory"},
36- {"/jump", "scroll the chat to a previous turn (or /jump <text>)"},40+ {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
37- {"/btw", "side-chat that doesn't add to the main thread (saves tokens)"},41+ {Name: "/btw", Desc: "side-chat that doesn't add to the main thread (saves tokens)"},
38- {"/skills", "list discovered skills (SKILL.md files)"},42+ {Name: "/skills", Desc: "list discovered skills (SKILL.md files)"},
39- {"/compact", "summarize and replace the transcript to free up context"},43+ {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
40- {"/lock", "confine tools to the current directory"},44+ {Name: "/lock", Desc: "confine tools to the current directory"},
41- {"/unlock", "allow tools to touch paths outside this directory"},45+ {Name: "/unlock", Desc: "allow tools to touch paths outside this directory"},
42- {"/clear", "clear the chat transcript"},46+ {Name: "/clear", Desc: "clear the chat transcript"},
43- {"/exit", "exit zot"},47+ {Name: "/exit", Desc: "exit zot"},
44 }48 }
45 49
46 // slashSuggester renders the popup that appears when the editor starts50 // slashSuggester renders the popup that appears when the editor starts
@@ -50,23 +54,38 @@ type slashSuggester struct {
50 54
51 // extra are commands contributed by extensions, refreshed each55 // extra are commands contributed by extensions, refreshed each
52 // frame from the extension manager. Empty when no extensions56 // frame from the extension manager. Empty when no extensions
53- // have registered any.57+ // have registered any. Sorted by name in SetExtra so map
58+ // iteration order doesn't reshuffle the popup between frames.
54 extra []slashCommand59 extra []slashCommand
60+
61+ // lastMatches is the list shown in the most recent Render call.
62+ // Up/Down read it so they know which indexes to skip across
63+ // header rows.
64+ lastMatches []slashCommand
55 }65 }
56 66
57 // SetExtra updates the extension-contributed command list. Called67 // SetExtra updates the extension-contributed command list. Called
58 // once per render with the live snapshot from the extension manager.68 // once per render with the live snapshot from the extension manager.
59-func (s *slashSuggester) SetExtra(cmds []slashCommand) { s.extra = cmds }69+// The list is sorted by name so the popup ordering stays stable
70+// across redraws (Manager.Commands() iterates a map, which Go
71+// randomises).
72+func (s *slashSuggester) SetExtra(cmds []slashCommand) {
73+ sorted := append([]slashCommand(nil), cmds...)
74+ sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
75+ s.extra = sorted
76+}
60 77
61 // allCatalog returns slashCatalog plus the current extra commands78 // allCatalog returns slashCatalog plus the current extra commands
62-// (extension-registered). Extra entries are only kept if they don't79+// (extension-registered) with a header divider between the two
63-// collide with a built-in name; the built-in always wins.80+// groups. Extra entries are only kept if they don't collide with
81+// a built-in name; the built-in always wins.
64 func (s *slashSuggester) allCatalog() []slashCommand {82 func (s *slashSuggester) allCatalog() []slashCommand {
65 if len(s.extra) == 0 {83 if len(s.extra) == 0 {
66 return slashCatalog84 return slashCatalog
67 }85 }
68- out := make([]slashCommand, 0, len(slashCatalog)+len(s.extra))86+ out := make([]slashCommand, 0, len(slashCatalog)+len(s.extra)+1)
69 out = append(out, slashCatalog...)87 out = append(out, slashCatalog...)
88+ var kept []slashCommand
70 for _, c := range s.extra {89 for _, c := range s.extra {
71 dup := false90 dup := false
72 for _, b := range slashCatalog {91 for _, b := range slashCatalog {
@@ -76,9 +95,13 @@ func (s *slashSuggester) allCatalog() []slashCommand {
76 }95 }
77 }96 }
78 if !dup {97 if !dup {
79- out = append(out, c)98+ kept = append(kept, c)
80 }99 }
81 }100 }
101+ if len(kept) > 0 {
102+ out = append(out, slashCommand{Header: true, Name: "extensions"})
103+ out = append(out, kept...)
104+ }
82 return out105 return out
83 }106 }
84 107
@@ -147,14 +170,46 @@ func (s *slashSuggester) matches(input string) []slashCommand {
147 }170 }
148 var out []slashCommand171 var out []slashCommand
149 for _, c := range s.allCatalog() {172 for _, c := range s.allCatalog() {
173+ if c.Header {
174+ // Headers ride along whenever there's at least one
175+ // matching command from their group; we drop trailing
176+ // orphan headers below.
177+ out = append(out, c)
178+ continue
179+ }
150 if strings.HasPrefix(c.Name, input) {180 if strings.HasPrefix(c.Name, input) {
151 out = append(out, c)181 out = append(out, c)
152 }182 }
153 }183 }
184+ return pruneOrphanHeaders(out)
185+}
186+
187+// pruneOrphanHeaders removes header rows that have no commands
188+// after them (i.e. the next non-header is missing or another
189+// header). Keeps the popup clean when the input filters out a whole
190+// group.
191+func pruneOrphanHeaders(in []slashCommand) []slashCommand {
192+ out := make([]slashCommand, 0, len(in))
193+ for i, c := range in {
194+ if c.Header {
195+ nextReal := false
196+ for j := i + 1; j < len(in); j++ {
197+ if !in[j].Header {
198+ nextReal = true
199+ break
200+ }
201+ }
202+ if !nextReal {
203+ continue
204+ }
205+ }
206+ out = append(out, c)
207+ }
154 return out208 return out
155 }209 }
156 210
157-// clampCursor keeps the cursor inside the current match list.211+// clampCursor keeps the cursor inside the current match list and
212+// nudges it past any header row so navigation never lands on one.
158 func (s *slashSuggester) clampCursor(n int) {213 func (s *slashSuggester) clampCursor(n int) {
159 if n <= 0 {214 if n <= 0 {
160 s.cursor = 0215 s.cursor = 0
@@ -168,9 +223,51 @@ func (s *slashSuggester) clampCursor(n int) {
168 }223 }
169 }224 }
170 225
171-// Up / Down navigate the suggestion list.226+// Up / Down navigate the suggestion list, skipping header rows in
172-func (s *slashSuggester) Up() { s.cursor-- }227+// either direction so the cursor only ever lands on selectable
173-func (s *slashSuggester) Down() { s.cursor++ }228+// commands.
229+func (s *slashSuggester) Up() {
230+ s.skipHeader(-1)
231+}
232+func (s *slashSuggester) Down() {
233+ s.skipHeader(+1)
234+}
235+
236+// skipHeader moves the cursor by step, then keeps moving in the same
237+// direction across header rows until it lands on a real command (or
238+// hits the edge, in which case it bounces back to the nearest real
239+// row).
240+func (s *slashSuggester) skipHeader(step int) {
241+ list := s.lastMatches
242+ n := len(list)
243+ if n == 0 {
244+ return
245+ }
246+ s.cursor += step
247+ for s.cursor >= 0 && s.cursor < n && list[s.cursor].Header {
248+ s.cursor += step
249+ }
250+ if s.cursor < 0 {
251+ // Bounce: find the first non-header from the top.
252+ for i, c := range list {
253+ if !c.Header {
254+ s.cursor = i
255+ return
256+ }
257+ }
258+ s.cursor = 0
259+ }
260+ if s.cursor >= n {
261+ // Bounce: find the last non-header.
262+ for i := n - 1; i >= 0; i-- {
263+ if !list[i].Header {
264+ s.cursor = i
265+ return
266+ }
267+ }
268+ s.cursor = n - 1
269+ }
270+}
174 271
175 // Active reports whether the popup is visible for the given input.272 // Active reports whether the popup is visible for the given input.
176 func (s *slashSuggester) Active(input string) bool {273 func (s *slashSuggester) Active(input string) bool {
@@ -178,12 +275,25 @@ func (s *slashSuggester) Active(input string) bool {
178 }275 }
179 276
180 // Selection returns the currently highlighted command for input, or "".277 // Selection returns the currently highlighted command for input, or "".
278+// Headers are never returned even if the cursor index would point at
279+// one; the cursor is moved forward to the next real command.
181 func (s *slashSuggester) Selection(input string) string {280 func (s *slashSuggester) Selection(input string) string {
182 m := s.matches(input)281 m := s.matches(input)
183 if len(m) == 0 {282 if len(m) == 0 {
184 return ""283 return ""
185 }284 }
186 s.clampCursor(len(m))285 s.clampCursor(len(m))
286+ if m[s.cursor].Header {
287+ for i := s.cursor + 1; i < len(m); i++ {
288+ if !m[i].Header {
289+ s.cursor = i
290+ break
291+ }
292+ }
293+ }
294+ if m[s.cursor].Header {
295+ return ""
296+ }
187 return m[s.cursor].Name297 return m[s.cursor].Name
188 }298 }
189 299
@@ -193,9 +303,28 @@ func (s *slashSuggester) Render(input string, th tui.Theme, width int) []string
193 if len(m) == 0 {303 if len(m) == 0 {
194 return nil304 return nil
195 }305 }
306+ s.lastMatches = m
196 s.clampCursor(len(m))307 s.clampCursor(len(m))
308+ // Snap cursor off any header (e.g. after a filter change put it on one).
309+ if s.cursor >= 0 && s.cursor < len(m) && m[s.cursor].Header {
310+ for i := s.cursor + 1; i < len(m); i++ {
311+ if !m[i].Header {
312+ s.cursor = i
313+ break
314+ }
315+ }
316+ }
197 var lines []string317 var lines []string
198 for i, c := range m {318 for i, c := range m {
319+ if c.Header {
320+ rule := strings.Repeat("─", width)
321+ label := "── " + c.Name + " "
322+ if len(label) < width {
323+ rule = label + strings.Repeat("─", width-len(label))
324+ }
325+ lines = append(lines, th.FG256(th.Muted, rule))
326+ continue
327+ }
199 name := c.Name328 name := c.Name
200 if len(name) < 10 {329 if len(name) < 10 {
201 name = name + strings.Repeat(" ", 10-len(name))330 name = name + strings.Repeat(" ", 10-len(name))

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