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() { | |||
| 537 | 537 | catalog := i.cfg.Extensions.Commands() | |
| 538 | 538 | extra := make([]slashCommand, 0, len(catalog)) | |
| 539 | 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 | 548 | extra = append(extra, slashCommand{ | |
| 541 | 549 | Name: "/" + c.Name, | |
| 542 | - | Desc: c.Description + " (ext: " + c.Extension + ")", | |
| 550 | + | Desc: desc, | |
| 543 | 551 | }) | |
| 544 | 552 | } | |
| 545 | 553 | 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sort" | |
| 4 | 5 | "strings" | |
| 5 | 6 | ||
| 6 | 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 | 13 | 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 | |
| 13 | 17 | } | |
| 14 | 18 | ||
| 15 | 19 | // slashCancelsTurn reports whether the named slash command, when run | |
| @@ -28,19 +32,19 @@ func slashCancelsTurn(head string) bool { | |||
| 28 | 32 | // slashCatalog lists every slash command the interactive mode handles. | |
| 29 | 33 | // Keep in sync with runSlash(). | |
| 30 | 34 | 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"}, | |
| 44 | 48 | } | |
| 45 | 49 | ||
| 46 | 50 | // slashSuggester renders the popup that appears when the editor starts | |
| @@ -50,23 +54,38 @@ type slashSuggester struct { | |||
| 50 | 54 | ||
| 51 | 55 | // extra are commands contributed by extensions, refreshed each | |
| 52 | 56 | // 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 | 59 | 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 | 67 | // SetExtra updates the extension-contributed command list. Called | |
| 58 | 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 | 78 | // 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. | |
| 64 | 82 | func (s *slashSuggester) allCatalog() []slashCommand { | |
| 65 | 83 | if len(s.extra) == 0 { | |
| 66 | 84 | 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 | 87 | out = append(out, slashCatalog...) | |
| 88 | + | var kept []slashCommand | |
| 70 | 89 | for _, c := range s.extra { | |
| 71 | 90 | dup := false | |
| 72 | 91 | for _, b := range slashCatalog { | |
| @@ -76,9 +95,13 @@ func (s *slashSuggester) allCatalog() []slashCommand { | |||
| 76 | 95 | } | |
| 77 | 96 | } | |
| 78 | 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 | 105 | return out | |
| 83 | 106 | } | |
| 84 | 107 | ||
| @@ -147,14 +170,46 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||
| 147 | 170 | } | |
| 148 | 171 | var out []slashCommand | |
| 149 | 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 | 180 | if strings.HasPrefix(c.Name, input) { | |
| 151 | 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 | 208 | 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 | 213 | func (s *slashSuggester) clampCursor(n int) { | |
| 159 | 214 | if n <= 0 { | |
| 160 | 215 | 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. | |
| 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 | + | } | |
| 174 | 271 | ||
| 175 | 272 | // Active reports whether the popup is visible for the given input. | |
| 176 | 273 | func (s *slashSuggester) Active(input string) bool { | |
| @@ -178,12 +275,25 @@ func (s *slashSuggester) Active(input string) bool { | |||
| 178 | 275 | } | |
| 179 | 276 | ||
| 180 | 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 | 280 | func (s *slashSuggester) Selection(input string) string { | |
| 182 | 281 | m := s.matches(input) | |
| 183 | 282 | if len(m) == 0 { | |
| 184 | 283 | return "" | |
| 185 | 284 | } | |
| 186 | 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 | 297 | 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 | 303 | if len(m) == 0 { | |
| 194 | 304 | return nil | |
| 195 | 305 | } | |
| 306 | + | s.lastMatches = m | |
| 196 | 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 | 317 | var lines []string | |
| 198 | 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 | 328 | name := c.Name | |
| 200 | 329 | if len(name) < 10 { | |
| 201 | 330 | name = name + strings.Repeat(" ", 10-len(name)) | |
| @@ -1,15 +1,19 @@ | |||||
| 1 | package modes | 1 | 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 string | 15 | + | Desc string |
| 16 | + | Header bool // true = visual divider, not selectable | |||
| 13 | } | 17 | } | ||
| 14 | 18 | ||||
| 15 | // slashCancelsTurn reports whether the named slash command, when run | 19 | // 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 starts | 50 | // 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 each | 55 | // extra are commands contributed by extensions, refreshed each | ||
| 52 | // frame from the extension manager. Empty when no extensions | 56 | // 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 []slashCommand | 59 | 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. Called | 67 | // 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 commands | 78 | // allCatalog returns slashCatalog plus the current extra commands | ||
| 62 | - | // (extension-registered). Extra entries are only kept if they don't | 79 | + | // (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 slashCatalog | 84 | 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 := false | 90 | 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 out | 105 | return out | ||
| 83 | } | 106 | } | ||
| 84 | 107 | ||||
| @@ -147,14 +170,46 @@ func (s *slashSuggester) matches(input string) []slashCommand { | |||||
| 147 | } | 170 | } | ||
| 148 | var out []slashCommand | 171 | 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 out | 208 | 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 = 0 | 215 | 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].Name | 297 | 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 nil | 304 | 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 []string | 317 | 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.Name | 328 | 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)) | ||