detached b8602e4

refactor(palette): simplify typed result flow

commit
b8602e4a5d7d330a98fa9aaae583ce8dc55640b1
author
brice · · 2026-07-15 06:46 UTC
committer
brice · · 2026-07-15 07:47 UTC
parent
6909543
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +184 -167
M internal/usecase/daemon/palette.go +7 -8
@@ -178,13 +178,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
178178 return
179179 }
180180 rawQuery = ac.overlays.palette.Query()
181- switch result := selected.(type) {
182- case palette.CommandResult:
183- cmd = result.Command()
181+ if selectedCommand, ok := selected.CommandInfo(); ok {
182+ cmd = selectedCommand
184183 // JRS is contextual: fuzzy selection cannot turn an unrelated
185184 // query into a jump.
186185 if cmd.Slug == "jump-recent-session" {
187- action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)
186+ action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery)
188187 if !valid {
189188 changed = true
190189 return
@@ -192,10 +191,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
192191 args = action.Args
193192 recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
194193 }
195- case palette.SessionResult:
196- sessionTarget = result
194+ } else if selectedSession, ok := selected.Session(); ok {
195+ sessionTarget = selectedSession
197196 hasSessionTarget = true
198- default:
197+ } else {
199198 changed = true
200199 return
201200 }
@@ -276,7 +275,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
276275 }
277276
278277 func paletteArgs(query string, cmd command.Command) []string {
279- action, ok := palette.ParseAction([]command.Command{cmd}, query)
278+ action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
280279 if !ok {
281280 return nil
282281 }
@@ -178,13 +178,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
178 return178 return
179 }179 }
180 rawQuery = ac.overlays.palette.Query()180 rawQuery = ac.overlays.palette.Query()
181- switch result := selected.(type) {181+ if selectedCommand, ok := selected.CommandInfo(); ok {
182- case palette.CommandResult:182+ cmd = selectedCommand
183- cmd = result.Command()
184 // JRS is contextual: fuzzy selection cannot turn an unrelated183 // JRS is contextual: fuzzy selection cannot turn an unrelated
185 // query into a jump.184 // query into a jump.
186 if cmd.Slug == "jump-recent-session" {185 if cmd.Slug == "jump-recent-session" {
187- action, valid := palette.ParseAction([]command.Command{cmd}, rawQuery)186+ action, valid := palette.ParseAction([]palette.Result{selected}, rawQuery)
188 if !valid {187 if !valid {
189 changed = true188 changed = true
190 return189 return
@@ -192,10 +191,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
192 args = action.Args191 args = action.Args
193 recent = append([]recentSession(nil), ac.overlays.paletteRecent...)192 recent = append([]recentSession(nil), ac.overlays.paletteRecent...)
194 }193 }
195- case palette.SessionResult:194+ } else if selectedSession, ok := selected.Session(); ok {
196- sessionTarget = result195+ sessionTarget = selectedSession
197 hasSessionTarget = true196 hasSessionTarget = true
198- default:197+ } else {
199 changed = true198 changed = true
200 return199 return
201 }200 }
@@ -276,7 +275,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
276 }275 }
277 276
278 func paletteArgs(query string, cmd command.Command) []string {277 func paletteArgs(query string, cmd command.Command) []string {
279- action, ok := palette.ParseAction([]command.Command{cmd}, query)278+ action, ok := palette.ParseAction(palette.CommandResults([]command.Command{cmd}), query)
280 if !ok {279 if !ok {
281 return nil280 return nil
282 }281 }
M internal/usecase/palette/action.go +11 -9
@@ -13,16 +13,17 @@ type Action struct {
1313 Args []string
1414 }
1515
16-// ParseAction recognizes an exact effective command token. It accepts outer
17-// and repeated whitespace but never treats a prefix or concatenated token as
18-// a command. Static commands reject arguments.
19-func ParseAction(commands []command.Command, input string) (Action, bool) {
16+// ParseAction recognizes an exact effective command result token. It accepts
17+// outer and repeated whitespace but never treats a prefix or concatenated token
18+// as a command. Static commands reject arguments.
19+func ParseAction(results []Result, input string) (Action, bool) {
2020 fields := strings.Fields(input)
2121 if len(fields) == 0 {
2222 return Action{}, false
2323 }
24- for _, cmd := range commands {
25- if !strings.EqualFold(fields[0], cmd.Code) {
24+ for _, result := range results {
25+ cmd, ok := result.CommandInfo()
26+ if !ok || !strings.EqualFold(fields[0], cmd.Code) {
2627 continue
2728 }
2829 args := append([]string(nil), fields[1:]...)
@@ -43,13 +44,14 @@ func ParseAction(commands []command.Command, input string) (Action, bool) {
4344
4445 // ArgumentCommand returns the exact argument-taking command whose token is
4546 // being typed, including the whitespace-before-argument state.
46-func ArgumentCommand(commands []command.Command, input string) (command.Command, bool) {
47+func ArgumentCommand(results []Result, input string) (command.Command, bool) {
4748 fields := strings.Fields(input)
4849 if len(fields) == 0 {
4950 return command.Command{}, false
5051 }
51- for _, cmd := range commands {
52- if cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
52+ for _, result := range results {
53+ cmd, ok := result.CommandInfo()
54+ if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
5355 return cmd, true
5456 }
5557 }
@@ -13,16 +13,17 @@ type Action struct {
13 Args []string13 Args []string
14 }14 }
15 15
16-// ParseAction recognizes an exact effective command token. It accepts outer16+// ParseAction recognizes an exact effective command result token. It accepts
17-// and repeated whitespace but never treats a prefix or concatenated token as17+// outer and repeated whitespace but never treats a prefix or concatenated token
18-// a command. Static commands reject arguments.18+// as a command. Static commands reject arguments.
19-func ParseAction(commands []command.Command, input string) (Action, bool) {19+func ParseAction(results []Result, input string) (Action, bool) {
20 fields := strings.Fields(input)20 fields := strings.Fields(input)
21 if len(fields) == 0 {21 if len(fields) == 0 {
22 return Action{}, false22 return Action{}, false
23 }23 }
24- for _, cmd := range commands {24+ for _, result := range results {
25- if !strings.EqualFold(fields[0], cmd.Code) {25+ cmd, ok := result.CommandInfo()
26+ if !ok || !strings.EqualFold(fields[0], cmd.Code) {
26 continue27 continue
27 }28 }
28 args := append([]string(nil), fields[1:]...)29 args := append([]string(nil), fields[1:]...)
@@ -43,13 +44,14 @@ func ParseAction(commands []command.Command, input string) (Action, bool) {
43 44
44 // ArgumentCommand returns the exact argument-taking command whose token is45 // ArgumentCommand returns the exact argument-taking command whose token is
45 // being typed, including the whitespace-before-argument state.46 // being typed, including the whitespace-before-argument state.
46-func ArgumentCommand(commands []command.Command, input string) (command.Command, bool) {47+func ArgumentCommand(results []Result, input string) (command.Command, bool) {
47 fields := strings.Fields(input)48 fields := strings.Fields(input)
48 if len(fields) == 0 {49 if len(fields) == 0 {
49 return command.Command{}, false50 return command.Command{}, false
50 }51 }
51- for _, cmd := range commands {52+ for _, result := range results {
52- if cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {53+ cmd, ok := result.CommandInfo()
54+ if ok && cmd.Arguments == command.ArgumentsRequired && strings.EqualFold(fields[0], cmd.Code) {
53 return cmd, true55 return cmd, true
54 }56 }
55 }57 }
M internal/usecase/palette/action_test.go +4 -4
@@ -7,10 +7,10 @@ import (
77 )
88
99 func TestParseActionExactEffectiveToken(t *testing.T) {
10- commands := []command.Command{
10+ results := CommandResults([]command.Command{
1111 {Code: "BSK", Arguments: command.ArgumentsNone},
1212 {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},
13- }
13+ })
1414 tests := []struct {
1515 name string
1616 input string
@@ -27,7 +27,7 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
2727 }
2828 for _, tt := range tests {
2929 t.Run(tt.name, func(t *testing.T) {
30- action, ok := ParseAction(commands, tt.input)
30+ action, ok := ParseAction(results, tt.input)
3131 if ok != tt.ok {
3232 t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok)
3333 }
@@ -43,7 +43,7 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
4343
4444 func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
4545 cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
46- if got, ok := ArgumentCommand([]command.Command{cmd}, "JRS 1"); !ok || got.Code != "JRS" {
46+ if got, ok := ArgumentCommand(CommandResults([]command.Command{cmd}), "JRS 1"); !ok || got.Code != "JRS" {
4747 t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok)
4848 }
4949 }
@@ -7,10 +7,10 @@ import (
7 )7 )
8 8
9 func TestParseActionExactEffectiveToken(t *testing.T) {9 func TestParseActionExactEffectiveToken(t *testing.T) {
10- commands := []command.Command{10+ results := CommandResults([]command.Command{
11 {Code: "BSK", Arguments: command.ArgumentsNone},11 {Code: "BSK", Arguments: command.ArgumentsNone},
12 {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},12 {Code: "JRS", Arguments: command.ArgumentsRequired, ContextHint: command.ContextHintRecentSessions},
13- }13+ })
14 tests := []struct {14 tests := []struct {
15 name string15 name string
16 input string16 input string
@@ -27,7 +27,7 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
27 }27 }
28 for _, tt := range tests {28 for _, tt := range tests {
29 t.Run(tt.name, func(t *testing.T) {29 t.Run(tt.name, func(t *testing.T) {
30- action, ok := ParseAction(commands, tt.input)30+ action, ok := ParseAction(results, tt.input)
31 if ok != tt.ok {31 if ok != tt.ok {
32 t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok)32 t.Fatalf("ParseAction(%q) ok = %v, want %v", tt.input, ok, tt.ok)
33 }33 }
@@ -43,7 +43,7 @@ func TestParseActionExactEffectiveToken(t *testing.T) {
43 43
44 func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {44 func TestArgumentCommandKeepsExactRowVisible(t *testing.T) {
45 cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}45 cmd := command.Command{Code: "JRS", Arguments: command.ArgumentsRequired}
46- if got, ok := ArgumentCommand([]command.Command{cmd}, "JRS 1"); !ok || got.Code != "JRS" {46+ if got, ok := ArgumentCommand(CommandResults([]command.Command{cmd}), "JRS 1"); !ok || got.Code != "JRS" {
47 t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok)47 t.Fatalf("ArgumentCommand() = %#v, %v; want JRS, true", got, ok)
48 }48 }
49 }49 }
M internal/usecase/palette/fuzzy.go +22 -51
@@ -3,38 +3,27 @@ package palette
33 import (
44 "sort"
55 "strings"
6-
7- "github.com/bnema/vev/internal/usecase/command"
6+ "unicode/utf8"
87 )
98
10-// Match is a fuzzy-matched immutable palette result. Command is retained for
11-// compatibility with command-only callers; session results leave it empty.
9+// Match is a fuzzy-matched immutable palette result.
1210 type Match struct {
1311 Result Result
14- Command command.Command
1512 Positions []int
1613 rank int
1714 span int
1815 first int
1916 order int
17+ search string
2018 }
2119
22-// Fuzzy searches either typed results or the legacy command-only input. New
23-// callers should provide []Result so session targets remain typed end-to-end.
24-func Fuzzy(items any, query string) []Match {
25- var results []Result
26- switch values := items.(type) {
27- case []Result:
28- results = append([]Result(nil), values...)
29- case []command.Command:
30- results = commandResults(values)
31- default:
32- return nil
33- }
20+// Fuzzy searches typed immutable palette results.
21+func Fuzzy(results []Result, query string) []Match {
3422 return fuzzyResults(results, query)
3523 }
3624
3725 func fuzzyResults(results []Result, query string) []Match {
26+ needle := strings.ToLower(query)
3827 if query == "" {
3928 out := make([]Match, 0, len(results))
4029 for i, result := range results {
@@ -45,12 +34,13 @@ func fuzzyResults(results []Result, query string) []Match {
4534 return out
4635 }
4736
37+ needleRunes := []rune(needle)
4838 var out []Match
4939 for i, result := range results {
5040 if result == nil {
5141 continue
5242 }
53- if match, ok := score(result, query, i); ok {
43+ if match, ok := score(result, needle, needleRunes, i); ok {
5444 out = append(out, match)
5545 }
5646 }
@@ -68,44 +58,42 @@ func fuzzyResults(results []Result, query string) []Match {
6858 if a.Result.Kind() != b.Result.Kind() {
6959 return a.Result.Kind() < b.Result.Kind()
7060 }
71- if aText, bText := normalizedSearchText(a.Result), normalizedSearchText(b.Result); aText != bText {
72- return aText < bText
61+ if a.search != b.search {
62+ return a.search < b.search
7363 }
7464 return a.order < b.order
7565 })
7666 return out
7767 }
7868
79-func score(result Result, query string, order int) (Match, bool) {
80- text := normalizedSearchText(result)
81- needle := strings.ToLower(query)
82- textRunes, queryRunes := []rune(text), []rune(needle)
69+func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) {
8370 match := newMatch(result, order)
71+ text := match.search
8472
8573 // An exact command shortcode always wins over every other match. The
8674 // remaining results then share exact/prefix/subsequence scoring.
8775 if result.Kind() == ResultKindCommand && text == needle {
88- match.Positions = rangePositions(len(textRunes))
89- match.rank, match.span, match.first = 0, len(textRunes), 0
76+ match.Positions = rangePositions(utf8.RuneCountInString(text))
77+ match.rank, match.span, match.first = 0, len(match.Positions), 0
9078 return match, true
9179 }
9280 if text == needle {
93- match.Positions = rangePositions(len(textRunes))
94- match.rank, match.span, match.first = 1, len(textRunes), 0
81+ match.Positions = rangePositions(utf8.RuneCountInString(text))
82+ match.rank, match.span, match.first = 1, len(match.Positions), 0
9583 return match, true
9684 }
9785 if strings.HasPrefix(text, needle) {
98- match.Positions = rangePositions(len(queryRunes))
99- match.rank, match.span, match.first = 2, len(queryRunes), 0
86+ match.Positions = rangePositions(utf8.RuneCountInString(needle))
87+ match.rank, match.span, match.first = 2, len(match.Positions), 0
10088 return match, true
10189 }
102- if positions, ok := subsequencePositions(textRunes, queryRunes); ok {
90+ if positions, ok := subsequencePositions([]rune(text), needleRunes); ok {
10391 match.Positions = positions
10492 match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0]
10593 return match, true
10694 }
107- if cmd, ok := resultCommand(result); ok {
108- if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), queryRunes); ok {
95+ if cmd, ok := result.CommandInfo(); ok {
96+ if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
10997 match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0]
11098 return match, true
11199 }
@@ -114,24 +102,7 @@ func score(result Result, query string, order int) (Match, bool) {
114102 }
115103
116104 func newMatch(result Result, order int) Match {
117- match := Match{Result: result, order: order}
118- if cmd, ok := resultCommand(result); ok {
119- match.Command = cmd
120- }
121- return match
122-}
123-
124-func normalizedSearchText(result Result) string { return strings.ToLower(result.SearchText()) }
125-
126-func resultCommand(result Result) (command.Command, bool) {
127- switch result := result.(type) {
128- case CommandResult:
129- return result.Command(), true
130- case *CommandResult:
131- return result.Command(), true
132- default:
133- return command.Command{}, false
134- }
105+ return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}
135106 }
136107
137108 func rangePositions(n int) []int {
@@ -3,38 +3,27 @@ package palette
3 import (3 import (
4 "sort"4 "sort"
5 "strings"5 "strings"
6-6+ "unicode/utf8"
7- "github.com/bnema/vev/internal/usecase/command"
8 )7 )
9 8
10-// Match is a fuzzy-matched immutable palette result. Command is retained for9+// Match is a fuzzy-matched immutable palette result.
11-// compatibility with command-only callers; session results leave it empty.
12 type Match struct {10 type Match struct {
13 Result Result11 Result Result
14- Command command.Command
15 Positions []int12 Positions []int
16 rank int13 rank int
17 span int14 span int
18 first int15 first int
19 order int16 order int
17+ search string
20 }18 }
21 19
22-// Fuzzy searches either typed results or the legacy command-only input. New20+// Fuzzy searches typed immutable palette results.
23-// callers should provide []Result so session targets remain typed end-to-end.21+func Fuzzy(results []Result, query string) []Match {
24-func Fuzzy(items any, query string) []Match {
25- var results []Result
26- switch values := items.(type) {
27- case []Result:
28- results = append([]Result(nil), values...)
29- case []command.Command:
30- results = commandResults(values)
31- default:
32- return nil
33- }
34 return fuzzyResults(results, query)22 return fuzzyResults(results, query)
35 }23 }
36 24
37 func fuzzyResults(results []Result, query string) []Match {25 func fuzzyResults(results []Result, query string) []Match {
26+ needle := strings.ToLower(query)
38 if query == "" {27 if query == "" {
39 out := make([]Match, 0, len(results))28 out := make([]Match, 0, len(results))
40 for i, result := range results {29 for i, result := range results {
@@ -45,12 +34,13 @@ func fuzzyResults(results []Result, query string) []Match {
45 return out34 return out
46 }35 }
47 36
37+ needleRunes := []rune(needle)
48 var out []Match38 var out []Match
49 for i, result := range results {39 for i, result := range results {
50 if result == nil {40 if result == nil {
51 continue41 continue
52 }42 }
53- if match, ok := score(result, query, i); ok {43+ if match, ok := score(result, needle, needleRunes, i); ok {
54 out = append(out, match)44 out = append(out, match)
55 }45 }
56 }46 }
@@ -68,44 +58,42 @@ func fuzzyResults(results []Result, query string) []Match {
68 if a.Result.Kind() != b.Result.Kind() {58 if a.Result.Kind() != b.Result.Kind() {
69 return a.Result.Kind() < b.Result.Kind()59 return a.Result.Kind() < b.Result.Kind()
70 }60 }
71- if aText, bText := normalizedSearchText(a.Result), normalizedSearchText(b.Result); aText != bText {61+ if a.search != b.search {
72- return aText < bText62+ return a.search < b.search
73 }63 }
74 return a.order < b.order64 return a.order < b.order
75 })65 })
76 return out66 return out
77 }67 }
78 68
79-func score(result Result, query string, order int) (Match, bool) {69+func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) {
80- text := normalizedSearchText(result)
81- needle := strings.ToLower(query)
82- textRunes, queryRunes := []rune(text), []rune(needle)
83 match := newMatch(result, order)70 match := newMatch(result, order)
71+ text := match.search
84 72
85 // An exact command shortcode always wins over every other match. The73 // An exact command shortcode always wins over every other match. The
86 // remaining results then share exact/prefix/subsequence scoring.74 // remaining results then share exact/prefix/subsequence scoring.
87 if result.Kind() == ResultKindCommand && text == needle {75 if result.Kind() == ResultKindCommand && text == needle {
88- match.Positions = rangePositions(len(textRunes))76+ match.Positions = rangePositions(utf8.RuneCountInString(text))
89- match.rank, match.span, match.first = 0, len(textRunes), 077+ match.rank, match.span, match.first = 0, len(match.Positions), 0
90 return match, true78 return match, true
91 }79 }
92 if text == needle {80 if text == needle {
93- match.Positions = rangePositions(len(textRunes))81+ match.Positions = rangePositions(utf8.RuneCountInString(text))
94- match.rank, match.span, match.first = 1, len(textRunes), 082+ match.rank, match.span, match.first = 1, len(match.Positions), 0
95 return match, true83 return match, true
96 }84 }
97 if strings.HasPrefix(text, needle) {85 if strings.HasPrefix(text, needle) {
98- match.Positions = rangePositions(len(queryRunes))86+ match.Positions = rangePositions(utf8.RuneCountInString(needle))
99- match.rank, match.span, match.first = 2, len(queryRunes), 087+ match.rank, match.span, match.first = 2, len(match.Positions), 0
100 return match, true88 return match, true
101 }89 }
102- if positions, ok := subsequencePositions(textRunes, queryRunes); ok {90+ if positions, ok := subsequencePositions([]rune(text), needleRunes); ok {
103 match.Positions = positions91 match.Positions = positions
104 match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0]92 match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0]
105 return match, true93 return match, true
106 }94 }
107- if cmd, ok := resultCommand(result); ok {95+ if cmd, ok := result.CommandInfo(); ok {
108- if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), queryRunes); ok {96+ if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok {
109 match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0]97 match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0]
110 return match, true98 return match, true
111 }99 }
@@ -114,24 +102,7 @@ func score(result Result, query string, order int) (Match, bool) {
114 }102 }
115 103
116 func newMatch(result Result, order int) Match {104 func newMatch(result Result, order int) Match {
117- match := Match{Result: result, order: order}105+ return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}
118- if cmd, ok := resultCommand(result); ok {
119- match.Command = cmd
120- }
121- return match
122-}
123-
124-func normalizedSearchText(result Result) string { return strings.ToLower(result.SearchText()) }
125-
126-func resultCommand(result Result) (command.Command, bool) {
127- switch result := result.(type) {
128- case CommandResult:
129- return result.Command(), true
130- case *CommandResult:
131- return result.Command(), true
132- default:
133- return command.Command{}, false
134- }
135 }106 }
136 107
137 func rangePositions(n int) []int {108 func rangePositions(n int) []int {
M internal/usecase/palette/fuzzy_test.go +41 -10
@@ -16,52 +16,56 @@ func cmd(code, name, desc string) command.Command {
1616 func codes(matches []Match) []string {
1717 out := make([]string, len(matches))
1818 for i, match := range matches {
19- out[i] = match.Command.Code
19+ cmd, ok := match.Result.CommandInfo()
20+ if !ok {
21+ continue
22+ }
23+ out[i] = cmd.Code
2024 }
2125 return out
2226 }
2327
2428 func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {
25- matches := Fuzzy([]command.Command{
29+ matches := Fuzzy(CommandResults([]command.Command{
2630 cmd("ZQQ", "unused", "alpha beta"),
2731 cmd("XAB", "unused", "subsequence code"),
2832 cmd("ABX", "unused", "prefix code"),
2933 cmd("AB", "unused", "exact code"),
30- }, "ab")
34+ }), "ab")
3135
3236 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
3337 require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")
3438 }
3539
3640 func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {
37- matches := Fuzzy([]command.Command{
41+ matches := Fuzzy(CommandResults([]command.Command{
3842 cmd("CPY", "Create Alpha Beta", "Enter copy mode"),
3943 cmd("ZQQ", "unused", "Alpha Beta tools"),
40- }, "ab")
44+ }), "ab")
4145
4246 require.Equal(t, []string{"ZQQ"}, codes(matches))
4347 }
4448
4549 func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {
46- matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy")
50+ matches := Fuzzy(CommandResults([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}), "copy")
4751
4852 require.Len(t, matches, 1)
4953 require.Empty(t, matches[0].Positions)
5054 }
5155
5256 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
53- matches := Fuzzy([]command.Command{
57+ matches := Fuzzy(CommandResults([]command.Command{
5458 cmd("ZAXQB", "", ""), // span 3, first 1
5559 cmd("BAXQ", "", ""), // span 3, first 1, code sorts first
5660 cmd("PXAQ", "", ""), // span 2, first 2
5761 cmd("BAQ", "", ""), // span 2, first 0
58- }, "aq")
62+ }), "aq")
5963
6064 require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches))
6165 }
6266
6367 func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
64- matches := Fuzzy([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}, "cy")
68+ matches := Fuzzy(CommandResults([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}), "cy")
6569
6670 require.Len(t, matches, 1)
6771 require.Equal(t, []int{0, 2}, matches[0].Positions)
@@ -69,7 +73,7 @@ func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
6973
7074 func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
7175 commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")}
72- matches := Fuzzy(commands, "")
76+ matches := Fuzzy(CommandResults(commands), "")
7377
7478 require.Equal(t, []string{"B", "A"}, codes(matches))
7579 }
@@ -98,6 +102,33 @@ func TestFuzzyMixedResultsPrioritizeCommandShortcodeAndRankSessions(t *testing.T
98102 require.Empty(t, matches[4].Positions)
99103 }
100104
105+func TestFuzzyCachesNormalizedSearchTextPerMatch(t *testing.T) {
106+ calls := 0
107+ results := []Result{
108+ countingResult{text: "aBravo", calls: &calls},
109+ countingResult{text: "aAlpha", calls: &calls},
110+ }
111+
112+ matches := Fuzzy(results, "a")
113+
114+ require.Len(t, matches, 2)
115+ require.Equal(t, 2, calls, "sorting must use the normalized text cached in Match")
116+}
117+
118+type countingResult struct {
119+ text string
120+ calls *int
121+}
122+
123+func (r countingResult) Kind() ResultKind { return ResultKindStoppedSession }
124+func (r countingResult) DisplayText() string { return r.text }
125+func (r countingResult) SearchText() string {
126+ (*r.calls)++
127+ return r.text
128+}
129+func (r countingResult) CommandInfo() (command.Command, bool) { return command.Command{}, false }
130+func (r countingResult) Session() (SessionResult, bool) { return SessionResult{}, false }
131+
101132 func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) {
102133 created := time.Time{}
103134 results := []Result{
@@ -16,52 +16,56 @@ func cmd(code, name, desc string) command.Command {
16 func codes(matches []Match) []string {16 func codes(matches []Match) []string {
17 out := make([]string, len(matches))17 out := make([]string, len(matches))
18 for i, match := range matches {18 for i, match := range matches {
19- out[i] = match.Command.Code19+ cmd, ok := match.Result.CommandInfo()
20+ if !ok {
21+ continue
22+ }
23+ out[i] = cmd.Code
20 }24 }
21 return out25 return out
22 }26 }
23 27
24 func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {28 func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {
25- matches := Fuzzy([]command.Command{29+ matches := Fuzzy(CommandResults([]command.Command{
26 cmd("ZQQ", "unused", "alpha beta"),30 cmd("ZQQ", "unused", "alpha beta"),
27 cmd("XAB", "unused", "subsequence code"),31 cmd("XAB", "unused", "subsequence code"),
28 cmd("ABX", "unused", "prefix code"),32 cmd("ABX", "unused", "prefix code"),
29 cmd("AB", "unused", "exact code"),33 cmd("AB", "unused", "exact code"),
30- }, "ab")34+ }), "ab")
31 35
32 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))36 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
33 require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")37 require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")
34 }38 }
35 39
36 func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {40 func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {
37- matches := Fuzzy([]command.Command{41+ matches := Fuzzy(CommandResults([]command.Command{
38 cmd("CPY", "Create Alpha Beta", "Enter copy mode"),42 cmd("CPY", "Create Alpha Beta", "Enter copy mode"),
39 cmd("ZQQ", "unused", "Alpha Beta tools"),43 cmd("ZQQ", "unused", "Alpha Beta tools"),
40- }, "ab")44+ }), "ab")
41 45
42 require.Equal(t, []string{"ZQQ"}, codes(matches))46 require.Equal(t, []string{"ZQQ"}, codes(matches))
43 }47 }
44 48
45 func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {49 func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {
46- matches := Fuzzy([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}, "copy")50+ matches := Fuzzy(CommandResults([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}), "copy")
47 51
48 require.Len(t, matches, 1)52 require.Len(t, matches, 1)
49 require.Empty(t, matches[0].Positions)53 require.Empty(t, matches[0].Positions)
50 }54 }
51 55
52 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {56 func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
53- matches := Fuzzy([]command.Command{57+ matches := Fuzzy(CommandResults([]command.Command{
54 cmd("ZAXQB", "", ""), // span 3, first 158 cmd("ZAXQB", "", ""), // span 3, first 1
55 cmd("BAXQ", "", ""), // span 3, first 1, code sorts first59 cmd("BAXQ", "", ""), // span 3, first 1, code sorts first
56 cmd("PXAQ", "", ""), // span 2, first 260 cmd("PXAQ", "", ""), // span 2, first 2
57 cmd("BAQ", "", ""), // span 2, first 061 cmd("BAQ", "", ""), // span 2, first 0
58- }, "aq")62+ }), "aq")
59 63
60 require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches))64 require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches))
61 }65 }
62 66
63 func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {67 func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
64- matches := Fuzzy([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}, "cy")68+ matches := Fuzzy(CommandResults([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}), "cy")
65 69
66 require.Len(t, matches, 1)70 require.Len(t, matches, 1)
67 require.Equal(t, []int{0, 2}, matches[0].Positions)71 require.Equal(t, []int{0, 2}, matches[0].Positions)
@@ -69,7 +73,7 @@ func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
69 73
70 func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {74 func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
71 commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")}75 commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")}
72- matches := Fuzzy(commands, "")76+ matches := Fuzzy(CommandResults(commands), "")
73 77
74 require.Equal(t, []string{"B", "A"}, codes(matches))78 require.Equal(t, []string{"B", "A"}, codes(matches))
75 }79 }
@@ -98,6 +102,33 @@ func TestFuzzyMixedResultsPrioritizeCommandShortcodeAndRankSessions(t *testing.T
98 require.Empty(t, matches[4].Positions)102 require.Empty(t, matches[4].Positions)
99 }103 }
100 104
105+func TestFuzzyCachesNormalizedSearchTextPerMatch(t *testing.T) {
106+ calls := 0
107+ results := []Result{
108+ countingResult{text: "aBravo", calls: &calls},
109+ countingResult{text: "aAlpha", calls: &calls},
110+ }
111+
112+ matches := Fuzzy(results, "a")
113+
114+ require.Len(t, matches, 2)
115+ require.Equal(t, 2, calls, "sorting must use the normalized text cached in Match")
116+}
117+
118+type countingResult struct {
119+ text string
120+ calls *int
121+}
122+
123+func (r countingResult) Kind() ResultKind { return ResultKindStoppedSession }
124+func (r countingResult) DisplayText() string { return r.text }
125+func (r countingResult) SearchText() string {
126+ (*r.calls)++
127+ return r.text
128+}
129+func (r countingResult) CommandInfo() (command.Command, bool) { return command.Command{}, false }
130+func (r countingResult) Session() (SessionResult, bool) { return SessionResult{}, false }
131+
101 func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) {132 func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) {
102 created := time.Time{}133 created := time.Time{}
103 results := []Result{134 results := []Result{
M internal/usecase/palette/model.go +56 -52
@@ -2,6 +2,7 @@ package palette
22
33 import (
44 "unicode"
5+ "unicode/utf8"
56
67 "github.com/bnema/vev/internal/domain"
78 "github.com/bnema/vev/internal/usecase/command"
@@ -27,38 +28,20 @@ type RenderOptions struct {
2728
2829 type Model struct {
2930 results []Result
30- commands []command.Command
3131 input ui.TextInput
3232 matches []Match
3333 selected int
3434 scroll int
3535 }
3636
37-// New accepts typed results. []command.Command remains supported while callers
38-// migrate, and is immediately converted to immutable CommandResults.
39-func New(items any) *Model {
40- m := &Model{results: paletteResults(items)}
41- for _, result := range m.results {
42- if cmd, ok := resultCommand(result); ok {
43- m.commands = append(m.commands, cmd)
44- }
45- }
37+// New accepts typed immutable palette results.
38+func New(results []Result) *Model {
39+ m := &Model{results: append([]Result(nil), results...)}
4640 m.refresh()
4741 return m
4842 }
4943
50-func paletteResults(items any) []Result {
51- switch values := items.(type) {
52- case []Result:
53- return append([]Result(nil), values...)
54- case []command.Command:
55- return commandResults(values)
56- default:
57- return nil
58- }
59-}
60-
61-func NewRegistry() *Model { return New(commandResults(command.Registry())) }
44+func NewRegistry() *Model { return New(CommandResults(command.Registry())) }
6245
6346 func DefaultRenderStyles() RenderStyles {
6447 selection := renderer.DefaultStyle()
@@ -117,7 +100,7 @@ func (m *Model) CompleteSelected() bool {
117100 if !ok {
118101 return false
119102 }
120- cmd, ok := resultCommand(selected)
103+ cmd, ok := selected.CommandInfo()
121104 if !ok {
122105 return false
123106 }
@@ -166,7 +149,7 @@ func (m *Model) ArgumentCommand() (command.Command, bool) {
166149 if m == nil {
167150 return command.Command{}, false
168151 }
169- return ArgumentCommand(m.commands, m.Query())
152+ return ArgumentCommand(m.results, m.Query())
170153 }
171154
172155 func (m *Model) Matches() []Match {
@@ -186,16 +169,16 @@ func (m *Model) refresh() {
186169 m.matches = fuzzyResults(m.results, query)
187170 // Once an argument-taking static token is exact, retain its row while its
188171 // arguments make ordinary fuzzy matching inapplicable.
189- if cmd, ok := ArgumentCommand(m.commands, query); ok {
190- m.prependMatch(cmd)
172+ if result, ok := m.argumentResult(query); ok {
173+ m.prependMatch(result)
191174 } else if len(m.matches) == 0 {
192175 // Arguments are not part of static command matching. When they would
193176 // otherwise clear the list, keep required-argument candidates matched by
194177 // the partial first token so Tab can complete them.
195178 token, _, hasSeparator := completionParts(query)
196179 if hasSeparator {
197- for _, match := range fuzzyResults(commandResults(m.commands), token) {
198- if match.Command.Arguments == command.ArgumentsRequired {
180+ for _, match := range fuzzyResults(m.results, token) {
181+ if cmd, ok := match.Result.CommandInfo(); ok && cmd.Arguments == command.ArgumentsRequired {
199182 m.matches = append(m.matches, match)
200183 }
201184 }
@@ -204,9 +187,25 @@ func (m *Model) refresh() {
204187 m.clamp()
205188 }
206189
207-func (m *Model) prependMatch(cmd command.Command) {
190+func (m *Model) argumentResult(input string) (Result, bool) {
191+ cmd, ok := ArgumentCommand(m.results, input)
192+ if !ok {
193+ return nil, false
194+ }
195+ for _, result := range m.results {
196+ candidate, ok := result.CommandInfo()
197+ if ok && candidate.Code == cmd.Code {
198+ return result, true
199+ }
200+ }
201+ return nil, false
202+}
203+
204+func (m *Model) prependMatch(result Result) {
205+ cmd, _ := result.CommandInfo()
208206 for i, match := range m.matches {
209- if match.Command.Code != cmd.Code {
207+ candidate, ok := match.Result.CommandInfo()
208+ if !ok || candidate.Code != cmd.Code {
210209 continue
211210 }
212211 if i > 0 {
@@ -215,7 +214,7 @@ func (m *Model) prependMatch(cmd command.Command) {
215214 }
216215 return
217216 }
218- m.matches = append([]Match{{Result: NewCommandResult(cmd), Command: cmd}}, m.matches...)
217+ m.matches = append([]Match{newMatch(result, 0)}, m.matches...)
219218 }
220219 func (m *Model) clamp() {
221220 if len(m.matches) == 0 {
@@ -261,8 +260,8 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
261260 }
262261 codeWidth := 0
263262 for _, match := range m.matches {
264- if cmd, ok := resultCommand(match.Result); ok && len([]rune(cmd.Code)) > codeWidth {
265- codeWidth = len([]rune(cmd.Code))
263+ if cmd, ok := match.Result.CommandInfo(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
264+ codeWidth = utf8.RuneCountInString(cmd.Code)
266265 }
267266 }
268267 activeCmd, activeOK := m.ArgumentCommand()
@@ -277,7 +276,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
277276 style = selection
278277 }
279278 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
280- if cmd, ok := resultCommand(match.Result); ok {
279+ if cmd, ok := match.Result.CommandInfo(); ok {
281280 m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
282281 continue
283282 }
@@ -287,16 +286,14 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
287286 }
288287
289288 func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {
290- x, highlight := 0, map[int]bool{}
291- for _, position := range positions {
292- highlight[position] = true
293- }
294- for i, r := range []rune(cmd.Code) {
289+ x, nextHighlight := 0, 0
290+ for _, r := range cmd.Code {
295291 cellStyle := style
296292 cellStyle.Bold = true
297- if highlight[i] {
293+ if nextHighlight < len(positions) && positions[nextHighlight] == x {
298294 cellStyle = selection
299295 cellStyle.Bold = true
296+ nextHighlight++
300297 }
301298 if x < frame.Width {
302299 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
@@ -317,25 +314,32 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
317314 }
318315
319316 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
320- text := []rune(match.Result.DisplayText())
321- if feedback != "" && selected {
322- text = append(text, ' ')
323- text = append(text, []rune(feedback)...)
324- }
325- highlight := map[int]bool{}
326- prefix := len([]rune("Switch to session "))
327- for _, position := range match.Positions {
328- highlight[prefix+position] = true
329- }
330- for x, r := range text {
317+ x, nextHighlight := 0, 0
318+ prefixWidth := utf8.RuneCountInString(sessionDisplayPrefix)
319+ for _, r := range match.Result.DisplayText() {
331320 cellStyle := style
332- if highlight[x] {
321+ if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x {
333322 cellStyle = selection
334323 cellStyle.Bold = true
324+ nextHighlight++
335325 }
336326 if x < frame.Width {
337327 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
338328 }
329+ x++
330+ }
331+ if feedback == "" || !selected {
332+ return
333+ }
334+ if x < frame.Width {
335+ frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
336+ }
337+ x++
338+ for _, r := range feedback {
339+ if x < frame.Width {
340+ frame.Set(x, y, renderer.Cell{Rune: r, Style: style})
341+ }
342+ x++
339343 }
340344 }
341345
@@ -2,6 +2,7 @@ package palette
2 2
3 import (3 import (
4 "unicode"4 "unicode"
5+ "unicode/utf8"
5 6
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
@@ -27,38 +28,20 @@ type RenderOptions struct {
27 28
28 type Model struct {29 type Model struct {
29 results []Result30 results []Result
30- commands []command.Command
31 input ui.TextInput31 input ui.TextInput
32 matches []Match32 matches []Match
33 selected int33 selected int
34 scroll int34 scroll int
35 }35 }
36 36
37-// New accepts typed results. []command.Command remains supported while callers37+// New accepts typed immutable palette results.
38-// migrate, and is immediately converted to immutable CommandResults.38+func New(results []Result) *Model {
39-func New(items any) *Model {39+ m := &Model{results: append([]Result(nil), results...)}
40- m := &Model{results: paletteResults(items)}
41- for _, result := range m.results {
42- if cmd, ok := resultCommand(result); ok {
43- m.commands = append(m.commands, cmd)
44- }
45- }
46 m.refresh()40 m.refresh()
47 return m41 return m
48 }42 }
49 43
50-func paletteResults(items any) []Result {44+func NewRegistry() *Model { return New(CommandResults(command.Registry())) }
51- switch values := items.(type) {
52- case []Result:
53- return append([]Result(nil), values...)
54- case []command.Command:
55- return commandResults(values)
56- default:
57- return nil
58- }
59-}
60-
61-func NewRegistry() *Model { return New(commandResults(command.Registry())) }
62 45
63 func DefaultRenderStyles() RenderStyles {46 func DefaultRenderStyles() RenderStyles {
64 selection := renderer.DefaultStyle()47 selection := renderer.DefaultStyle()
@@ -117,7 +100,7 @@ func (m *Model) CompleteSelected() bool {
117 if !ok {100 if !ok {
118 return false101 return false
119 }102 }
120- cmd, ok := resultCommand(selected)103+ cmd, ok := selected.CommandInfo()
121 if !ok {104 if !ok {
122 return false105 return false
123 }106 }
@@ -166,7 +149,7 @@ func (m *Model) ArgumentCommand() (command.Command, bool) {
166 if m == nil {149 if m == nil {
167 return command.Command{}, false150 return command.Command{}, false
168 }151 }
169- return ArgumentCommand(m.commands, m.Query())152+ return ArgumentCommand(m.results, m.Query())
170 }153 }
171 154
172 func (m *Model) Matches() []Match {155 func (m *Model) Matches() []Match {
@@ -186,16 +169,16 @@ func (m *Model) refresh() {
186 m.matches = fuzzyResults(m.results, query)169 m.matches = fuzzyResults(m.results, query)
187 // Once an argument-taking static token is exact, retain its row while its170 // Once an argument-taking static token is exact, retain its row while its
188 // arguments make ordinary fuzzy matching inapplicable.171 // arguments make ordinary fuzzy matching inapplicable.
189- if cmd, ok := ArgumentCommand(m.commands, query); ok {172+ if result, ok := m.argumentResult(query); ok {
190- m.prependMatch(cmd)173+ m.prependMatch(result)
191 } else if len(m.matches) == 0 {174 } else if len(m.matches) == 0 {
192 // Arguments are not part of static command matching. When they would175 // Arguments are not part of static command matching. When they would
193 // otherwise clear the list, keep required-argument candidates matched by176 // otherwise clear the list, keep required-argument candidates matched by
194 // the partial first token so Tab can complete them.177 // the partial first token so Tab can complete them.
195 token, _, hasSeparator := completionParts(query)178 token, _, hasSeparator := completionParts(query)
196 if hasSeparator {179 if hasSeparator {
197- for _, match := range fuzzyResults(commandResults(m.commands), token) {180+ for _, match := range fuzzyResults(m.results, token) {
198- if match.Command.Arguments == command.ArgumentsRequired {181+ if cmd, ok := match.Result.CommandInfo(); ok && cmd.Arguments == command.ArgumentsRequired {
199 m.matches = append(m.matches, match)182 m.matches = append(m.matches, match)
200 }183 }
201 }184 }
@@ -204,9 +187,25 @@ func (m *Model) refresh() {
204 m.clamp()187 m.clamp()
205 }188 }
206 189
207-func (m *Model) prependMatch(cmd command.Command) {190+func (m *Model) argumentResult(input string) (Result, bool) {
191+ cmd, ok := ArgumentCommand(m.results, input)
192+ if !ok {
193+ return nil, false
194+ }
195+ for _, result := range m.results {
196+ candidate, ok := result.CommandInfo()
197+ if ok && candidate.Code == cmd.Code {
198+ return result, true
199+ }
200+ }
201+ return nil, false
202+}
203+
204+func (m *Model) prependMatch(result Result) {
205+ cmd, _ := result.CommandInfo()
208 for i, match := range m.matches {206 for i, match := range m.matches {
209- if match.Command.Code != cmd.Code {207+ candidate, ok := match.Result.CommandInfo()
208+ if !ok || candidate.Code != cmd.Code {
210 continue209 continue
211 }210 }
212 if i > 0 {211 if i > 0 {
@@ -215,7 +214,7 @@ func (m *Model) prependMatch(cmd command.Command) {
215 }214 }
216 return215 return
217 }216 }
218- m.matches = append([]Match{{Result: NewCommandResult(cmd), Command: cmd}}, m.matches...)217+ m.matches = append([]Match{newMatch(result, 0)}, m.matches...)
219 }218 }
220 func (m *Model) clamp() {219 func (m *Model) clamp() {
221 if len(m.matches) == 0 {220 if len(m.matches) == 0 {
@@ -261,8 +260,8 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
261 }260 }
262 codeWidth := 0261 codeWidth := 0
263 for _, match := range m.matches {262 for _, match := range m.matches {
264- if cmd, ok := resultCommand(match.Result); ok && len([]rune(cmd.Code)) > codeWidth {263+ if cmd, ok := match.Result.CommandInfo(); ok && utf8.RuneCountInString(cmd.Code) > codeWidth {
265- codeWidth = len([]rune(cmd.Code))264+ codeWidth = utf8.RuneCountInString(cmd.Code)
266 }265 }
267 }266 }
268 activeCmd, activeOK := m.ArgumentCommand()267 activeCmd, activeOK := m.ArgumentCommand()
@@ -277,7 +276,7 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
277 style = selection276 style = selection
278 }277 }
279 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})278 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
280- if cmd, ok := resultCommand(match.Result); ok {279+ if cmd, ok := match.Result.CommandInfo(); ok {
281 m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)280 m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance, opts.Feedback, idx == m.selected)
282 continue281 continue
283 }282 }
@@ -287,16 +286,14 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
287 }286 }
288 287
289 func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {288 func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, desc renderer.Style, codeWidth int, cmd command.Command, positions []int, activeCmd command.Command, activeOK bool, guidance, feedback string, selected bool) {
290- x, highlight := 0, map[int]bool{}289+ x, nextHighlight := 0, 0
291- for _, position := range positions {290+ for _, r := range cmd.Code {
292- highlight[position] = true
293- }
294- for i, r := range []rune(cmd.Code) {
295 cellStyle := style291 cellStyle := style
296 cellStyle.Bold = true292 cellStyle.Bold = true
297- if highlight[i] {293+ if nextHighlight < len(positions) && positions[nextHighlight] == x {
298 cellStyle = selection294 cellStyle = selection
299 cellStyle.Bold = true295 cellStyle.Bold = true
296+ nextHighlight++
300 }297 }
301 if x < frame.Width {298 if x < frame.Width {
302 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})299 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
@@ -317,25 +314,32 @@ func (m *Model) renderCommand(frame renderer.Frame, y int, style, selection, des
317 }314 }
318 315
319 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {316 func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match, feedback string, selected bool) {
320- text := []rune(match.Result.DisplayText())317+ x, nextHighlight := 0, 0
321- if feedback != "" && selected {318+ prefixWidth := utf8.RuneCountInString(sessionDisplayPrefix)
322- text = append(text, ' ')319+ for _, r := range match.Result.DisplayText() {
323- text = append(text, []rune(feedback)...)
324- }
325- highlight := map[int]bool{}
326- prefix := len([]rune("Switch to session "))
327- for _, position := range match.Positions {
328- highlight[prefix+position] = true
329- }
330- for x, r := range text {
331 cellStyle := style320 cellStyle := style
332- if highlight[x] {321+ if nextHighlight < len(match.Positions) && prefixWidth+match.Positions[nextHighlight] == x {
333 cellStyle = selection322 cellStyle = selection
334 cellStyle.Bold = true323 cellStyle.Bold = true
324+ nextHighlight++
335 }325 }
336 if x < frame.Width {326 if x < frame.Width {
337 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})327 frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
338 }328 }
329+ x++
330+ }
331+ if feedback == "" || !selected {
332+ return
333+ }
334+ if x < frame.Width {
335+ frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
336+ }
337+ x++
338+ for _, r := range feedback {
339+ if x < frame.Width {
340+ frame.Set(x, y, renderer.Cell{Rune: r, Style: style})
341+ }
342+ x++
339 }343 }
340 }344 }
341 345
M internal/usecase/palette/model_test.go +24 -23
@@ -12,11 +12,11 @@ import (
1212 )
1313
1414 func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
15- m := New([]command.Command{
15+ m := New(CommandResults([]command.Command{
1616 cmd("ABC", "Alpha", "first"),
1717 cmd("DEF", "Delta", "second"),
1818 cmd("AXY", "Other", "third"),
19- })
19+ }))
2020
2121 require.Equal(t, "", m.Query())
2222 selected, ok := m.Selected()
@@ -46,7 +46,7 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
4646 }
4747
4848 func TestModelNoMatchesClearsSelection(t *testing.T) {
49- m := New([]command.Command{cmd("ABC", "Alpha", "first")})
49+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
5050 m.Insert('z')
5151
5252 _, ok := m.Selected()
@@ -55,7 +55,7 @@ func TestModelNoMatchesClearsSelection(t *testing.T) {
5555 }
5656
5757 func TestModelMatchesDeepCopiesPositions(t *testing.T) {
58- m := New([]command.Command{cmd("ABC", "Alpha", "first")})
58+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
5959 m.Insert('a')
6060
6161 matches := m.Matches()
@@ -75,8 +75,8 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
7575 cmd("AAA", "", "ZZZ 1"),
7676 zzz,
7777 }
78- want := Fuzzy(commands, "ZZZ 1")[1]
79- m := New(commands)
78+ want := Fuzzy(CommandResults(commands), "ZZZ 1")[1]
79+ m := New(CommandResults(commands))
8080
8181 for _, r := range "ZZZ 1" {
8282 m.Insert(r)
@@ -84,7 +84,7 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
8484
8585 matches := m.Matches()
8686 require.Len(t, matches, 2)
87- require.Equal(t, "ZZZ", matches[0].Command.Code)
87+ require.Equal(t, "ZZZ", matchCommandCode(t, matches[0]))
8888 require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")
8989 selected, ok := m.Selected()
9090 require.True(t, ok)
@@ -103,15 +103,11 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
103103 commands: func(zzz command.Command) []command.Command {
104104 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
105105 },
106- want: Match{Result: NewCommandResult(command.Command{
106+ want: newMatch(NewCommandResult(command.Command{
107107 Code: "ZZZ",
108108 Desc: "unmatched",
109109 Arguments: command.ArgumentsRequired,
110- }), Command: command.Command{
111- Code: "ZZZ",
112- Desc: "unmatched",
113- Arguments: command.ArgumentsRequired,
114- }},
110+ }), 0),
115111 wantLen: 2,
116112 },
117113 {
@@ -123,7 +119,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
123119 want: func() Match {
124120 zzz := cmd("ZZZ", "", "ZZZ 1")
125121 zzz.Arguments = command.ArgumentsRequired
126- return Fuzzy([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}, "ZZZ 1")[0]
122+ return Fuzzy(CommandResults([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}), "ZZZ 1")[0]
127123 }(),
128124 wantLen: 2,
129125 },
@@ -131,7 +127,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
131127 t.Run(tt.name, func(t *testing.T) {
132128 zzz := cmd("ZZZ", "", "unmatched")
133129 zzz.Arguments = command.ArgumentsRequired
134- m := New(tt.commands(zzz))
130+ m := New(CommandResults(tt.commands(zzz)))
135131 for _, r := range "ZZZ 1" {
136132 m.Insert(r)
137133 }
@@ -144,10 +140,10 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
144140 }
145141
146142 func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
147- m := New([]command.Command{
143+ m := New(CommandResults([]command.Command{
148144 cmd("CPY", "Copy", "Enter copy mode"),
149145 cmd("CNT", "New", "Create tab"),
150- })
146+ }))
151147 m.Insert('c')
152148 m.Insert('y')
153149 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})
@@ -170,7 +166,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
170166 }
171167
172168 func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
173- m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
169+ m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
174170 want := []rune("CPY Enter copy mode")
175171
176172 for _, cols := range []int{0, 1, 3, 4, 7} {
@@ -186,11 +182,16 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
186182
187183 func selectedCommandCode(t *testing.T, result Result) string {
188184 t.Helper()
189- cmd, ok := resultCommand(result)
185+ cmd, ok := result.CommandInfo()
190186 require.True(t, ok, "selected result is a command")
191187 return cmd.Code
192188 }
193189
190+func matchCommandCode(t *testing.T, match Match) string {
191+ t.Helper()
192+ return selectedCommandCode(t, match.Result)
193+}
194+
194195 func frameRow(frame renderer.Frame, y int) string {
195196 row := make([]rune, frame.Width)
196197 for x := range frame.Width {
@@ -259,7 +260,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
259260
260261 for _, tt := range tests {
261262 t.Run(tt.name, func(t *testing.T) {
262- m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
263+ m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
263264 m.Insert('c')
264265 m.Insert('y')
265266 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
@@ -306,7 +307,7 @@ func TestModelCompleteSelected(t *testing.T) {
306307 if tt.registry {
307308 m = NewRegistry()
308309 } else {
309- m = New(tt.commands)
310+ m = New(CommandResults(tt.commands))
310311 }
311312 for _, r := range tt.query {
312313 m.Insert(r)
@@ -352,7 +353,7 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
352353
353354 selected, ok := m.Selected()
354355 require.True(t, ok)
355- session, ok := selected.(SessionResult)
356+ session, ok := selected.Session()
356357 require.True(t, ok)
357358 id, active := session.SessionID()
358359 require.True(t, active)
@@ -377,7 +378,7 @@ func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
377378 jrs := cmd("JRS", "Jump", "Jump to recent session")
378379 jrs.Arguments = command.ArgumentsRequired
379380 jrs.ContextHint = command.ContextHintRecentSessions
380- m := New([]command.Command{jrs})
381+ m := New(CommandResults([]command.Command{jrs}))
381382 for _, r := range "JRS 1" {
382383 m.Insert(r)
383384 }
@@ -12,11 +12,11 @@ import (
12 )12 )
13 13
14 func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {14 func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
15- m := New([]command.Command{15+ m := New(CommandResults([]command.Command{
16 cmd("ABC", "Alpha", "first"),16 cmd("ABC", "Alpha", "first"),
17 cmd("DEF", "Delta", "second"),17 cmd("DEF", "Delta", "second"),
18 cmd("AXY", "Other", "third"),18 cmd("AXY", "Other", "third"),
19- })19+ }))
20 20
21 require.Equal(t, "", m.Query())21 require.Equal(t, "", m.Query())
22 selected, ok := m.Selected()22 selected, ok := m.Selected()
@@ -46,7 +46,7 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
46 }46 }
47 47
48 func TestModelNoMatchesClearsSelection(t *testing.T) {48 func TestModelNoMatchesClearsSelection(t *testing.T) {
49- m := New([]command.Command{cmd("ABC", "Alpha", "first")})49+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
50 m.Insert('z')50 m.Insert('z')
51 51
52 _, ok := m.Selected()52 _, ok := m.Selected()
@@ -55,7 +55,7 @@ func TestModelNoMatchesClearsSelection(t *testing.T) {
55 }55 }
56 56
57 func TestModelMatchesDeepCopiesPositions(t *testing.T) {57 func TestModelMatchesDeepCopiesPositions(t *testing.T) {
58- m := New([]command.Command{cmd("ABC", "Alpha", "first")})58+ m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
59 m.Insert('a')59 m.Insert('a')
60 60
61 matches := m.Matches()61 matches := m.Matches()
@@ -75,8 +75,8 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
75 cmd("AAA", "", "ZZZ 1"),75 cmd("AAA", "", "ZZZ 1"),
76 zzz,76 zzz,
77 }77 }
78- want := Fuzzy(commands, "ZZZ 1")[1]78+ want := Fuzzy(CommandResults(commands), "ZZZ 1")[1]
79- m := New(commands)79+ m := New(CommandResults(commands))
80 80
81 for _, r := range "ZZZ 1" {81 for _, r := range "ZZZ 1" {
82 m.Insert(r)82 m.Insert(r)
@@ -84,7 +84,7 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
84 84
85 matches := m.Matches()85 matches := m.Matches()
86 require.Len(t, matches, 2)86 require.Len(t, matches, 2)
87- require.Equal(t, "ZZZ", matches[0].Command.Code)87+ require.Equal(t, "ZZZ", matchCommandCode(t, matches[0]))
88 require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")88 require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")
89 selected, ok := m.Selected()89 selected, ok := m.Selected()
90 require.True(t, ok)90 require.True(t, ok)
@@ -103,15 +103,11 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
103 commands: func(zzz command.Command) []command.Command {103 commands: func(zzz command.Command) []command.Command {
104 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}104 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
105 },105 },
106- want: Match{Result: NewCommandResult(command.Command{106+ want: newMatch(NewCommandResult(command.Command{
107 Code: "ZZZ",107 Code: "ZZZ",
108 Desc: "unmatched",108 Desc: "unmatched",
109 Arguments: command.ArgumentsRequired,109 Arguments: command.ArgumentsRequired,
110- }), Command: command.Command{110+ }), 0),
111- Code: "ZZZ",
112- Desc: "unmatched",
113- Arguments: command.ArgumentsRequired,
114- }},
115 wantLen: 2,111 wantLen: 2,
116 },112 },
117 {113 {
@@ -123,7 +119,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
123 want: func() Match {119 want: func() Match {
124 zzz := cmd("ZZZ", "", "ZZZ 1")120 zzz := cmd("ZZZ", "", "ZZZ 1")
125 zzz.Arguments = command.ArgumentsRequired121 zzz.Arguments = command.ArgumentsRequired
126- return Fuzzy([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}, "ZZZ 1")[0]122+ return Fuzzy(CommandResults([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}), "ZZZ 1")[0]
127 }(),123 }(),
128 wantLen: 2,124 wantLen: 2,
129 },125 },
@@ -131,7 +127,7 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
131 t.Run(tt.name, func(t *testing.T) {127 t.Run(tt.name, func(t *testing.T) {
132 zzz := cmd("ZZZ", "", "unmatched")128 zzz := cmd("ZZZ", "", "unmatched")
133 zzz.Arguments = command.ArgumentsRequired129 zzz.Arguments = command.ArgumentsRequired
134- m := New(tt.commands(zzz))130+ m := New(CommandResults(tt.commands(zzz)))
135 for _, r := range "ZZZ 1" {131 for _, r := range "ZZZ 1" {
136 m.Insert(r)132 m.Insert(r)
137 }133 }
@@ -144,10 +140,10 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
144 }140 }
145 141
146 func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {142 func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
147- m := New([]command.Command{143+ m := New(CommandResults([]command.Command{
148 cmd("CPY", "Copy", "Enter copy mode"),144 cmd("CPY", "Copy", "Enter copy mode"),
149 cmd("CNT", "New", "Create tab"),145 cmd("CNT", "New", "Create tab"),
150- })146+ }))
151 m.Insert('c')147 m.Insert('c')
152 m.Insert('y')148 m.Insert('y')
153 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})149 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})
@@ -170,7 +166,7 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
170 }166 }
171 167
172 func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {168 func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
173- m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})169+ m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
174 want := []rune("CPY Enter copy mode")170 want := []rune("CPY Enter copy mode")
175 171
176 for _, cols := range []int{0, 1, 3, 4, 7} {172 for _, cols := range []int{0, 1, 3, 4, 7} {
@@ -186,11 +182,16 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
186 182
187 func selectedCommandCode(t *testing.T, result Result) string {183 func selectedCommandCode(t *testing.T, result Result) string {
188 t.Helper()184 t.Helper()
189- cmd, ok := resultCommand(result)185+ cmd, ok := result.CommandInfo()
190 require.True(t, ok, "selected result is a command")186 require.True(t, ok, "selected result is a command")
191 return cmd.Code187 return cmd.Code
192 }188 }
193 189
190+func matchCommandCode(t *testing.T, match Match) string {
191+ t.Helper()
192+ return selectedCommandCode(t, match.Result)
193+}
194+
194 func frameRow(frame renderer.Frame, y int) string {195 func frameRow(frame renderer.Frame, y int) string {
195 row := make([]rune, frame.Width)196 row := make([]rune, frame.Width)
196 for x := range frame.Width {197 for x := range frame.Width {
@@ -259,7 +260,7 @@ func TestRenderUsesConfiguredStyles(t *testing.T) {
259 260
260 for _, tt := range tests {261 for _, tt := range tests {
261 t.Run(tt.name, func(t *testing.T) {262 t.Run(tt.name, func(t *testing.T) {
262- m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})263+ m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
263 m.Insert('c')264 m.Insert('c')
264 m.Insert('y')265 m.Insert('y')
265 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})266 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
@@ -306,7 +307,7 @@ func TestModelCompleteSelected(t *testing.T) {
306 if tt.registry {307 if tt.registry {
307 m = NewRegistry()308 m = NewRegistry()
308 } else {309 } else {
309- m = New(tt.commands)310+ m = New(CommandResults(tt.commands))
310 }311 }
311 for _, r := range tt.query {312 for _, r := range tt.query {
312 m.Insert(r)313 m.Insert(r)
@@ -352,7 +353,7 @@ func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T
352 353
353 selected, ok := m.Selected()354 selected, ok := m.Selected()
354 require.True(t, ok)355 require.True(t, ok)
355- session, ok := selected.(SessionResult)356+ session, ok := selected.Session()
356 require.True(t, ok)357 require.True(t, ok)
357 id, active := session.SessionID()358 id, active := session.SessionID()
358 require.True(t, active)359 require.True(t, active)
@@ -377,7 +378,7 @@ func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
377 jrs := cmd("JRS", "Jump", "Jump to recent session")378 jrs := cmd("JRS", "Jump", "Jump to recent session")
378 jrs.Arguments = command.ArgumentsRequired379 jrs.Arguments = command.ArgumentsRequired
379 jrs.ContextHint = command.ContextHintRecentSessions380 jrs.ContextHint = command.ContextHintRecentSessions
380- m := New([]command.Command{jrs})381+ m := New(CommandResults([]command.Command{jrs}))
381 for _, r := range "JRS 1" {382 for _, r := range "JRS 1" {
382 m.Insert(r)383 m.Insert(r)
383 }384 }
M internal/usecase/palette/result.go +19 -10
@@ -16,12 +16,14 @@ const (
1616 ResultKindStoppedSession
1717 )
1818
19-// Result is a searchable palette target. Only CommandResult is executable as
20-// a command; session results deliberately carry no command semantics.
19+// Result is a searchable palette target. Command and session accessors make
20+// the target's capabilities explicit without callers needing type assertions.
2121 type Result interface {
2222 Kind() ResultKind
2323 DisplayText() string
2424 SearchText() string
25+ CommandInfo() (command.Command, bool)
26+ Session() (SessionResult, bool)
2527 }
2628
2729 // CommandResult is a static command palette target.
@@ -29,11 +31,13 @@ type CommandResult struct {
2931 command command.Command
3032 }
3133
32-func NewCommandResult(cmd command.Command) CommandResult { return CommandResult{command: cmd} }
33-func (r CommandResult) Kind() ResultKind { return ResultKindCommand }
34-func (r CommandResult) DisplayText() string { return r.command.Code }
35-func (r CommandResult) SearchText() string { return r.command.Code }
36-func (r CommandResult) Command() command.Command { return r.command }
34+func NewCommandResult(cmd command.Command) CommandResult { return CommandResult{command: cmd} }
35+func (r CommandResult) Kind() ResultKind { return ResultKindCommand }
36+func (r CommandResult) DisplayText() string { return r.command.Code }
37+func (r CommandResult) SearchText() string { return r.command.Code }
38+func (r CommandResult) Command() command.Command { return r.command }
39+func (r CommandResult) CommandInfo() (command.Command, bool) { return r.command, true }
40+func (r CommandResult) Session() (SessionResult, bool) { return SessionResult{}, false }
3741
3842 // SessionResult is an immutable named-session target. An active session has a
3943 // SessionID; a stopped session intentionally does not.
@@ -63,10 +67,15 @@ func (r SessionResult) CreatedAt() time.Time { return r.createdAt }
6367 func (r SessionResult) SessionID() (domain.SessionID, bool) {
6468 return r.sessionID, r.active
6569 }
66-func (r SessionResult) DisplayText() string { return "Switch to session " + r.name }
67-func (r SessionResult) SearchText() string { return r.name }
70+func (r SessionResult) DisplayText() string { return sessionDisplayPrefix + r.name }
71+func (r SessionResult) SearchText() string { return r.name }
72+func (r SessionResult) CommandInfo() (command.Command, bool) { return command.Command{}, false }
73+func (r SessionResult) Session() (SessionResult, bool) { return r, true }
6874
69-func commandResults(commands []command.Command) []Result {
75+const sessionDisplayPrefix = "Switch to session "
76+
77+// CommandResults converts static commands to typed palette targets.
78+func CommandResults(commands []command.Command) []Result {
7079 results := make([]Result, len(commands))
7180 for i, cmd := range commands {
7281 results[i] = NewCommandResult(cmd)
@@ -16,12 +16,14 @@ const (
16 ResultKindStoppedSession16 ResultKindStoppedSession
17 )17 )
18 18
19-// Result is a searchable palette target. Only CommandResult is executable as19+// Result is a searchable palette target. Command and session accessors make
20-// a command; session results deliberately carry no command semantics.20+// the target's capabilities explicit without callers needing type assertions.
21 type Result interface {21 type Result interface {
22 Kind() ResultKind22 Kind() ResultKind
23 DisplayText() string23 DisplayText() string
24 SearchText() string24 SearchText() string
25+ CommandInfo() (command.Command, bool)
26+ Session() (SessionResult, bool)
25 }27 }
26 28
27 // CommandResult is a static command palette target.29 // CommandResult is a static command palette target.
@@ -29,11 +31,13 @@ type CommandResult struct {
29 command command.Command31 command command.Command
30 }32 }
31 33
32-func NewCommandResult(cmd command.Command) CommandResult { return CommandResult{command: cmd} }34+func NewCommandResult(cmd command.Command) CommandResult { return CommandResult{command: cmd} }
33-func (r CommandResult) Kind() ResultKind { return ResultKindCommand }35+func (r CommandResult) Kind() ResultKind { return ResultKindCommand }
34-func (r CommandResult) DisplayText() string { return r.command.Code }36+func (r CommandResult) DisplayText() string { return r.command.Code }
35-func (r CommandResult) SearchText() string { return r.command.Code }37+func (r CommandResult) SearchText() string { return r.command.Code }
36-func (r CommandResult) Command() command.Command { return r.command }38+func (r CommandResult) Command() command.Command { return r.command }
39+func (r CommandResult) CommandInfo() (command.Command, bool) { return r.command, true }
40+func (r CommandResult) Session() (SessionResult, bool) { return SessionResult{}, false }
37 41
38 // SessionResult is an immutable named-session target. An active session has a42 // SessionResult is an immutable named-session target. An active session has a
39 // SessionID; a stopped session intentionally does not.43 // SessionID; a stopped session intentionally does not.
@@ -63,10 +67,15 @@ func (r SessionResult) CreatedAt() time.Time { return r.createdAt }
63 func (r SessionResult) SessionID() (domain.SessionID, bool) {67 func (r SessionResult) SessionID() (domain.SessionID, bool) {
64 return r.sessionID, r.active68 return r.sessionID, r.active
65 }69 }
66-func (r SessionResult) DisplayText() string { return "Switch to session " + r.name }70+func (r SessionResult) DisplayText() string { return sessionDisplayPrefix + r.name }
67-func (r SessionResult) SearchText() string { return r.name }71+func (r SessionResult) SearchText() string { return r.name }
72+func (r SessionResult) CommandInfo() (command.Command, bool) { return command.Command{}, false }
73+func (r SessionResult) Session() (SessionResult, bool) { return r, true }
68 74
69-func commandResults(commands []command.Command) []Result {75+const sessionDisplayPrefix = "Switch to session "
76+
77+// CommandResults converts static commands to typed palette targets.
78+func CommandResults(commands []command.Command) []Result {
70 results := make([]Result, len(commands))79 results := make([]Result, len(commands))
71 for i, cmd := range commands {80 for i, cmd := range commands {
72 results[i] = NewCommandResult(cmd)81 results[i] = NewCommandResult(cmd)

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