detached 4b73ee6

feat(palette): complete selected shortcodes

commit
4b73ee6c9258d9bdcafdb5f877bb29ea3714a62f
author
brice · · 2026-07-14 11:29 UTC
parent
5c6654f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +135 -10
M internal/usecase/palette/model.go +73 -10
@@ -1,6 +1,8 @@
11 package palette
22
33 import (
4+ "unicode"
5+
46 "github.com/bnema/vev/internal/domain"
57 "github.com/bnema/vev/internal/usecase/command"
68 "github.com/bnema/vev/internal/usecase/ui"
@@ -86,6 +88,54 @@ func (m *Model) Selected() (command.Command, bool) {
8688 return m.matches[m.selected].Command, true
8789 }
8890
91+// CompleteSelected replaces the query with the selected command's effective
92+// code. Required-argument commands retain the typed argument bytes.
93+func (m *Model) CompleteSelected() bool {
94+ selected, ok := m.Selected()
95+ if !ok {
96+ return false
97+ }
98+
99+ query := m.Query()
100+ completed := selected.Code
101+ if selected.Arguments == command.ArgumentsRequired {
102+ token, args, hasSeparator := completionParts(query)
103+ if token == selected.Code && hasSeparator && args != "" {
104+ return false
105+ }
106+ completed += " "
107+ if args != "" {
108+ completed += args
109+ }
110+ }
111+ if completed == query {
112+ return false
113+ }
114+
115+ m.input.SetValue(completed)
116+ m.selected = 0
117+ m.scroll = 0
118+ m.refresh()
119+ return true
120+}
121+
122+// completionParts splits the first token at Unicode whitespace and returns
123+// the argument beginning at its first non-whitespace byte.
124+func completionParts(query string) (token, args string, hasSeparator bool) {
125+ for i, r := range query {
126+ if !unicode.IsSpace(r) {
127+ continue
128+ }
129+ for j, argumentRune := range query[i:] {
130+ if !unicode.IsSpace(argumentRune) {
131+ return query[:i], query[i+j:], true
132+ }
133+ }
134+ return query[:i], "", true
135+ }
136+ return query, "", false
137+}
138+
89139 // ArgumentCommand returns the exact argument-taking command being entered.
90140 func (m *Model) ArgumentCommand() (command.Command, bool) {
91141 if m == nil {
@@ -107,23 +157,36 @@ func (m *Model) Matches() []Match {
107157 }
108158
109159 func (m *Model) refresh() {
110- m.matches = Fuzzy(m.commands, m.input.Value())
160+ query := m.input.Value()
161+ m.matches = Fuzzy(m.commands, query)
111162 // Once an argument-taking token is exact, retain its row while its
112163 // arguments make ordinary fuzzy matching inapplicable.
113- if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok {
114- found := false
115- for _, match := range m.matches {
116- if match.Command.Code == cmd.Code {
117- found = true
118- break
164+ if cmd, ok := ArgumentCommand(m.commands, query); ok {
165+ m.prependMatch(cmd)
166+ } else if len(m.matches) == 0 {
167+ // Arguments are not part of static command matching. When they would
168+ // otherwise clear the list, keep required-argument candidates matched by
169+ // the partial first token so Tab can complete them.
170+ token, _, hasSeparator := completionParts(query)
171+ if hasSeparator {
172+ for _, match := range Fuzzy(m.commands, token) {
173+ if match.Command.Arguments == command.ArgumentsRequired {
174+ m.matches = append(m.matches, match)
175+ }
119176 }
120177 }
121- if !found {
122- m.matches = append([]Match{{Command: cmd}}, m.matches...)
123- }
124178 }
125179 m.clamp()
126180 }
181+
182+func (m *Model) prependMatch(cmd command.Command) {
183+ for _, match := range m.matches {
184+ if match.Command.Code == cmd.Code {
185+ return
186+ }
187+ }
188+ m.matches = append([]Match{{Command: cmd}}, m.matches...)
189+}
127190 func (m *Model) clamp() {
128191 if len(m.matches) == 0 {
129192 m.selected = -1
@@ -1,6 +1,8 @@
1 package palette1 package palette
2 2
3 import (3 import (
4+ "unicode"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/command"7 "github.com/bnema/vev/internal/usecase/command"
6 "github.com/bnema/vev/internal/usecase/ui"8 "github.com/bnema/vev/internal/usecase/ui"
@@ -86,6 +88,54 @@ func (m *Model) Selected() (command.Command, bool) {
86 return m.matches[m.selected].Command, true88 return m.matches[m.selected].Command, true
87 }89 }
88 90
91+// CompleteSelected replaces the query with the selected command's effective
92+// code. Required-argument commands retain the typed argument bytes.
93+func (m *Model) CompleteSelected() bool {
94+ selected, ok := m.Selected()
95+ if !ok {
96+ return false
97+ }
98+
99+ query := m.Query()
100+ completed := selected.Code
101+ if selected.Arguments == command.ArgumentsRequired {
102+ token, args, hasSeparator := completionParts(query)
103+ if token == selected.Code && hasSeparator && args != "" {
104+ return false
105+ }
106+ completed += " "
107+ if args != "" {
108+ completed += args
109+ }
110+ }
111+ if completed == query {
112+ return false
113+ }
114+
115+ m.input.SetValue(completed)
116+ m.selected = 0
117+ m.scroll = 0
118+ m.refresh()
119+ return true
120+}
121+
122+// completionParts splits the first token at Unicode whitespace and returns
123+// the argument beginning at its first non-whitespace byte.
124+func completionParts(query string) (token, args string, hasSeparator bool) {
125+ for i, r := range query {
126+ if !unicode.IsSpace(r) {
127+ continue
128+ }
129+ for j, argumentRune := range query[i:] {
130+ if !unicode.IsSpace(argumentRune) {
131+ return query[:i], query[i+j:], true
132+ }
133+ }
134+ return query[:i], "", true
135+ }
136+ return query, "", false
137+}
138+
89 // ArgumentCommand returns the exact argument-taking command being entered.139 // ArgumentCommand returns the exact argument-taking command being entered.
90 func (m *Model) ArgumentCommand() (command.Command, bool) {140 func (m *Model) ArgumentCommand() (command.Command, bool) {
91 if m == nil {141 if m == nil {
@@ -107,23 +157,36 @@ func (m *Model) Matches() []Match {
107 }157 }
108 158
109 func (m *Model) refresh() {159 func (m *Model) refresh() {
110- m.matches = Fuzzy(m.commands, m.input.Value())160+ query := m.input.Value()
161+ m.matches = Fuzzy(m.commands, query)
111 // Once an argument-taking token is exact, retain its row while its162 // Once an argument-taking token is exact, retain its row while its
112 // arguments make ordinary fuzzy matching inapplicable.163 // arguments make ordinary fuzzy matching inapplicable.
113- if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok {164+ if cmd, ok := ArgumentCommand(m.commands, query); ok {
114- found := false165+ m.prependMatch(cmd)
115- for _, match := range m.matches {166+ } else if len(m.matches) == 0 {
116- if match.Command.Code == cmd.Code {167+ // Arguments are not part of static command matching. When they would
117- found = true168+ // otherwise clear the list, keep required-argument candidates matched by
118- break169+ // the partial first token so Tab can complete them.
170+ token, _, hasSeparator := completionParts(query)
171+ if hasSeparator {
172+ for _, match := range Fuzzy(m.commands, token) {
173+ if match.Command.Arguments == command.ArgumentsRequired {
174+ m.matches = append(m.matches, match)
175+ }
119 }176 }
120 }177 }
121- if !found {
122- m.matches = append([]Match{{Command: cmd}}, m.matches...)
123- }
124 }178 }
125 m.clamp()179 m.clamp()
126 }180 }
181+
182+func (m *Model) prependMatch(cmd command.Command) {
183+ for _, match := range m.matches {
184+ if match.Command.Code == cmd.Code {
185+ return
186+ }
187+ }
188+ m.matches = append([]Match{{Command: cmd}}, m.matches...)
189+}
127 func (m *Model) clamp() {190 func (m *Model) clamp() {
128 if len(m.matches) == 0 {191 if len(m.matches) == 0 {
129 m.selected = -1192 m.selected = -1
M internal/usecase/palette/model_test.go +62 -0
@@ -186,6 +186,68 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
186186 }
187187 }
188188
189+func TestModelCompleteSelected(t *testing.T) {
190+ jrs := cmd("JRS", "Jump", "Jump to recent session")
191+ jrs.Arguments = command.ArgumentsRequired
192+ registryFirst := command.Registry()[0]
193+
194+ tests := []struct {
195+ name string
196+ commands []command.Command
197+ registry bool
198+ query string
199+ down int
200+ want string
201+ wantChange bool
202+ rendered string
203+ }{
204+ {name: "nil model", want: "", wantChange: false},
205+ {name: "empty model", commands: nil, want: "", wantChange: false},
206+ {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
207+ {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
208+ {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
209+ {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
210+ {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
211+ {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
212+ {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
213+ {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true},
214+ {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false},
215+ {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
216+ {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
217+ }
218+
219+ for _, tt := range tests {
220+ t.Run(tt.name, func(t *testing.T) {
221+ var m *Model
222+ if tt.name != "nil model" {
223+ if tt.registry {
224+ m = NewRegistry()
225+ } else {
226+ m = New(tt.commands)
227+ }
228+ for _, r := range tt.query {
229+ m.Insert(r)
230+ }
231+ for range tt.down {
232+ m.Down()
233+ }
234+ }
235+
236+ changed := m.CompleteSelected()
237+ require.Equal(t, tt.wantChange, changed)
238+ if m == nil {
239+ require.Equal(t, tt.want, "")
240+ return
241+ }
242+ require.Equal(t, tt.want, m.Query())
243+ if tt.rendered != "" {
244+ frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
245+ require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
246+ }
247+ })
248+ }
249+}
250+
189251 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
190252 jrs := cmd("JRS", "Jump", "Jump to recent session")
191253 jrs.Arguments = command.ArgumentsRequired
@@ -186,6 +186,68 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
186 }186 }
187 }187 }
188 188
189+func TestModelCompleteSelected(t *testing.T) {
190+ jrs := cmd("JRS", "Jump", "Jump to recent session")
191+ jrs.Arguments = command.ArgumentsRequired
192+ registryFirst := command.Registry()[0]
193+
194+ tests := []struct {
195+ name string
196+ commands []command.Command
197+ registry bool
198+ query string
199+ down int
200+ want string
201+ wantChange bool
202+ rendered string
203+ }{
204+ {name: "nil model", want: "", wantChange: false},
205+ {name: "empty model", commands: nil, want: "", wantChange: false},
206+ {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
207+ {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
208+ {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
209+ {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
210+ {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
211+ {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
212+ {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
213+ {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true},
214+ {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false},
215+ {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
216+ {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
217+ }
218+
219+ for _, tt := range tests {
220+ t.Run(tt.name, func(t *testing.T) {
221+ var m *Model
222+ if tt.name != "nil model" {
223+ if tt.registry {
224+ m = NewRegistry()
225+ } else {
226+ m = New(tt.commands)
227+ }
228+ for _, r := range tt.query {
229+ m.Insert(r)
230+ }
231+ for range tt.down {
232+ m.Down()
233+ }
234+ }
235+
236+ changed := m.CompleteSelected()
237+ require.Equal(t, tt.wantChange, changed)
238+ if m == nil {
239+ require.Equal(t, tt.want, "")
240+ return
241+ }
242+ require.Equal(t, tt.want, m.Query())
243+ if tt.rendered != "" {
244+ frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
245+ require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
246+ }
247+ })
248+ }
249+}
250+
189 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {251 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
190 jrs := cmd("JRS", "Jump", "Jump to recent session")252 jrs := cmd("JRS", "Jump", "Jump to recent session")
191 jrs.Arguments = command.ArgumentsRequired253 jrs.Arguments = command.ArgumentsRequired

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