detached 916eeb2

feat(palette): show and search visible command fields

commit
916eeb25bed6c92e4b55019dbd26040c44dd86ab
author
brice · · 2026-07-09 23:27 UTC
committer
brice · · 2026-07-09 23:28 UTC
parent
31971ae
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +51 -12
M internal/usecase/palette/fuzzy.go +1 -1
@@ -63,7 +63,7 @@ func score(cmd command.Command, query string, order int) (Match, bool) {
6363 if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {
6464 return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
6565 }
66- text := strings.ToLower(cmd.Name + " " + cmd.Desc)
66+ text := strings.ToLower(cmd.Desc)
6767 if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {
6868 return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
6969 }
@@ -63,7 +63,7 @@ func score(cmd command.Command, query string, order int) (Match, bool) {
63 if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {63 if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {
64 return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true64 return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
65 }65 }
66- text := strings.ToLower(cmd.Name + " " + cmd.Desc)66+ text := strings.ToLower(cmd.Desc)
67 if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {67 if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {
68 return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true68 return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
69 }69 }
M internal/usecase/palette/fuzzy_test.go +19 -3
@@ -19,16 +19,32 @@ func codes(matches []Match) []string {
1919 return out
2020 }
2121
22-func TestFuzzyOrdersScoringTiers(t *testing.T) {
22+func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {
2323 matches := Fuzzy([]command.Command{
24- cmd("ZQQ", "alpha beta", "name subsequence"),
24+ cmd("ZQQ", "unused", "alpha beta"),
2525 cmd("XAB", "unused", "subsequence code"),
2626 cmd("ABX", "unused", "prefix code"),
2727 cmd("AB", "unused", "exact code"),
2828 }, "ab")
2929
3030 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
31- require.Empty(t, matches[3].Positions, "name/description matches do not expose code highlight positions")
31+ require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")
32+}
33+
34+func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {
35+ matches := Fuzzy([]command.Command{
36+ cmd("CPY", "Create Alpha Beta", "Enter copy mode"),
37+ cmd("ZQQ", "unused", "Alpha Beta tools"),
38+ }, "ab")
39+
40+ require.Equal(t, []string{"ZQQ"}, codes(matches))
41+}
42+
43+func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {
44+ matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy")
45+
46+ require.Len(t, matches, 1)
47+ require.Empty(t, matches[0].Positions)
3248 }
3349
3450 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
@@ -19,16 +19,32 @@ func codes(matches []Match) []string {
19 return out19 return out
20 }20 }
21 21
22-func TestFuzzyOrdersScoringTiers(t *testing.T) {22+func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {
23 matches := Fuzzy([]command.Command{23 matches := Fuzzy([]command.Command{
24- cmd("ZQQ", "alpha beta", "name subsequence"),24+ cmd("ZQQ", "unused", "alpha beta"),
25 cmd("XAB", "unused", "subsequence code"),25 cmd("XAB", "unused", "subsequence code"),
26 cmd("ABX", "unused", "prefix code"),26 cmd("ABX", "unused", "prefix code"),
27 cmd("AB", "unused", "exact code"),27 cmd("AB", "unused", "exact code"),
28 }, "ab")28 }, "ab")
29 29
30 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))30 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
31- require.Empty(t, matches[3].Positions, "name/description matches do not expose code highlight positions")31+ require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")
32+}
33+
34+func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {
35+ matches := Fuzzy([]command.Command{
36+ cmd("CPY", "Create Alpha Beta", "Enter copy mode"),
37+ cmd("ZQQ", "unused", "Alpha Beta tools"),
38+ }, "ab")
39+
40+ require.Equal(t, []string{"ZQQ"}, codes(matches))
41+}
42+
43+func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {
44+ matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy")
45+
46+ require.Len(t, matches, 1)
47+ require.Empty(t, matches[0].Positions)
32 }48 }
33 49
34 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {50 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
M internal/usecase/palette/model.go +0 -1
@@ -174,7 +174,6 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
174174 frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})
175175 x++
176176 }
177- x = ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — ", style)
178177 ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))
179178 }
180179 return frame
@@ -174,7 +174,6 @@ func (m *Model) Render(inner domain.Size, styles RenderStyles) renderer.Frame {
174 frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})174 frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style})
175 x++175 x++
176 }176 }
177- x = ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — ", style)
178 ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))177 ui.DrawText(frame, x, y+1, frame.Width, match.Command.Desc, mergePaletteDescStyle(style, desc))
179 }178 }
180 return frame179 return frame
M internal/usecase/palette/model_test.go +31 -7
@@ -1,6 +1,7 @@
11 package palette
22
33 import (
4+ "fmt"
45 "testing"
56
67 "github.com/bnema/vev/internal/domain"
@@ -66,7 +67,7 @@ func TestModelMatchesDeepCopiesPositions(t *testing.T) {
6667 require.Equal(t, []int{0}, fresh[0].Positions)
6768 }
6869
69-func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {
70+func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
7071 m := New([]command.Command{
7172 cmd("CPY", "Copy", "Enter copy mode"),
7273 cmd("CNT", "New", "Create tab"),
@@ -81,15 +82,38 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {
8182 require.Equal(t, 'y', frame.At(3, 0).Rune)
8283 require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")
8384
84- require.Equal(t, 'C', frame.At(0, 1).Rune)
85- require.Equal(t, 'P', frame.At(1, 1).Rune)
86- require.Equal(t, 'Y', frame.At(2, 1).Rune)
85+ require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1))
86+ require.NotContains(t, frameRow(frame, 1), "Copy")
87+ require.NotContains(t, frameRow(frame, 1), "—")
8788 require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")
8889 require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")
8990 require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold")
9091 require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold")
9192 require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold")
92- require.True(t, frame.At(13, 1).Style.Italic, "description is italic")
93+ require.True(t, frame.At(4, 1).Style.Italic, "description is italic")
94+}
95+
96+func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
97+ m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
98+ want := []rune("CPY Enter copy mode")
99+
100+ for _, cols := range []int{0, 1, 3, 4, 7} {
101+ t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {
102+ frame := m.Render(domain.Size{Cols: cols, Rows: 2}, DefaultRenderStyles())
103+ require.Equal(t, cols, frame.Width)
104+ if cols > 0 {
105+ require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
106+ }
107+ })
108+ }
109+}
110+
111+func frameRow(frame renderer.Frame, y int) string {
112+ row := make([]rune, frame.Width)
113+ for x := range frame.Width {
114+ row[x] = frame.At(x, y).Rune
115+ }
116+ return string(row)
93117 }
94118
95119 func TestRenderUsesConfiguredStyles(t *testing.T) {
@@ -128,7 +152,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
128152 styles.Description = muted
129153 return styles
130154 },
131- x: 13,
155+ x: 4,
132156 assert: func(t *testing.T, style renderer.Style) {
133157 require.True(t, style.Italic)
134158 require.True(t, style.HasForegroundRGB)
@@ -143,7 +167,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
143167 styles.Description.Italic = false
144168 return styles
145169 },
146- x: 13,
170+ x: 4,
147171 assert: func(t *testing.T, style renderer.Style) {
148172 require.False(t, style.Italic)
149173 },
@@ -1,6 +1,7 @@
1 package palette1 package palette
2 2
3 import (3 import (
4+ "fmt"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
@@ -66,7 +67,7 @@ func TestModelMatchesDeepCopiesPositions(t *testing.T) {
66 require.Equal(t, []int{0}, fresh[0].Positions)67 require.Equal(t, []int{0}, fresh[0].Positions)
67 }68 }
68 69
69-func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {70+func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
70 m := New([]command.Command{71 m := New([]command.Command{
71 cmd("CPY", "Copy", "Enter copy mode"),72 cmd("CPY", "Copy", "Enter copy mode"),
72 cmd("CNT", "New", "Create tab"),73 cmd("CNT", "New", "Create tab"),
@@ -81,15 +82,38 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {
81 require.Equal(t, 'y', frame.At(3, 0).Rune)82 require.Equal(t, 'y', frame.At(3, 0).Rune)
82 require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")83 require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")
83 84
84- require.Equal(t, 'C', frame.At(0, 1).Rune)85+ require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1))
85- require.Equal(t, 'P', frame.At(1, 1).Rune)86+ require.NotContains(t, frameRow(frame, 1), "Copy")
86- require.Equal(t, 'Y', frame.At(2, 1).Rune)87+ require.NotContains(t, frameRow(frame, 1), "—")
87 require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")88 require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")
88 require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")89 require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")
89 require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold")90 require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold")
90 require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold")91 require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold")
91 require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold")92 require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold")
92- require.True(t, frame.At(13, 1).Style.Italic, "description is italic")93+ require.True(t, frame.At(4, 1).Style.Italic, "description is italic")
94+}
95+
96+func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
97+ m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
98+ want := []rune("CPY Enter copy mode")
99+
100+ for _, cols := range []int{0, 1, 3, 4, 7} {
101+ t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {
102+ frame := m.Render(domain.Size{Cols: cols, Rows: 2}, DefaultRenderStyles())
103+ require.Equal(t, cols, frame.Width)
104+ if cols > 0 {
105+ require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
106+ }
107+ })
108+ }
109+}
110+
111+func frameRow(frame renderer.Frame, y int) string {
112+ row := make([]rune, frame.Width)
113+ for x := range frame.Width {
114+ row[x] = frame.At(x, y).Rune
115+ }
116+ return string(row)
93 }117 }
94 118
95 func TestRenderUsesConfiguredStyles(t *testing.T) {119 func TestRenderUsesConfiguredStyles(t *testing.T) {
@@ -128,7 +152,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
128 styles.Description = muted152 styles.Description = muted
129 return styles153 return styles
130 },154 },
131- x: 13,155+ x: 4,
132 assert: func(t *testing.T, style renderer.Style) {156 assert: func(t *testing.T, style renderer.Style) {
133 require.True(t, style.Italic)157 require.True(t, style.Italic)
134 require.True(t, style.HasForegroundRGB)158 require.True(t, style.HasForegroundRGB)
@@ -143,7 +167,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
143 styles.Description.Italic = false167 styles.Description.Italic = false
144 return styles168 return styles
145 },169 },
146- x: 13,170+ x: 4,
147 assert: func(t *testing.T, style renderer.Style) {171 assert: func(t *testing.T, style renderer.Style) {
148 require.False(t, style.Italic)172 require.False(t, style.Italic)
149 },173 },

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 12

Tags 11

↑↓ moveenter openesc close