fix(palette): prioritize exact session matches (#243)
- commit
- b09c5d7ab96fdba7a3686fb9d603785095c1093e
- author
- bnema · · 2026-08-30 16:24 UTC
- committer
- GitHub · · 2026-08-30 16:24 UTC
- parent
- 1fe5198
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
6 files · +179 -36
M internal/usecase/daemon/palette.go +1 -1
| @@ -267,7 +267,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 267 | 267 | if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) { | |
| 268 | 268 | continue | |
| 269 | 269 | } | |
| 270 | - | results = append(results, palette.NewRecentRouteResult(label, protocol.RouteNavigationAction{ | |
| 270 | + | results = append(results, palette.NewRecentRouteResult(entry.Target.SessionName, label, protocol.RouteNavigationAction{ | |
| 271 | 271 | SnapshotGeneration: routeSnapshot.Generation, | |
| 272 | 272 | Key: entry.Key, | |
| 273 | 273 | Generation: entry.Generation, | |
| @@ -267,7 +267,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 267 | if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) { | 267 | if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) { | ||
| 268 | continue | 268 | continue | ||
| 269 | } | 269 | } | ||
| 270 | - | results = append(results, palette.NewRecentRouteResult(label, protocol.RouteNavigationAction{ | 270 | + | results = append(results, palette.NewRecentRouteResult(entry.Target.SessionName, label, protocol.RouteNavigationAction{ |
| 271 | SnapshotGeneration: routeSnapshot.Generation, | 271 | SnapshotGeneration: routeSnapshot.Generation, | ||
| 272 | Key: entry.Key, | 272 | Key: entry.Key, | ||
| 273 | Generation: entry.Generation, | 273 | Generation: entry.Generation, | ||
M internal/usecase/palette/fuzzy.go +72 -21
| @@ -59,39 +59,90 @@ func Fuzzy(results []Result, query string) []Match { | |||
| 59 | 59 | ||
| 60 | 60 | func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) { | |
| 61 | 61 | match := newMatch(result, order) | |
| 62 | - | text := match.search | |
| 63 | 62 | ||
| 64 | - | // An exact command shortcode always wins over every other match. The | |
| 65 | - | // remaining results then share exact/prefix/subsequence scoring. | |
| 66 | - | if result.Kind() == ResultKindCommand && text == needle { | |
| 67 | - | match.Positions = rangePositions(utf8.RuneCountInString(text)) | |
| 68 | - | match.rank, match.span, match.first = 0, len(match.Positions), 0 | |
| 69 | - | return match, true | |
| 70 | - | } | |
| 71 | - | if text == needle { | |
| 72 | - | match.Positions = rangePositions(utf8.RuneCountInString(text)) | |
| 73 | - | match.rank, match.span, match.first = 1, len(match.Positions), 0 | |
| 74 | - | return match, true | |
| 75 | - | } | |
| 76 | - | if strings.HasPrefix(text, needle) { | |
| 77 | - | match.Positions = rangePositions(utf8.RuneCountInString(needle)) | |
| 78 | - | match.rank, match.span, match.first = 2, len(match.Positions), 0 | |
| 79 | - | return match, true | |
| 63 | + | identity, label, offset, hasTerms := result.searchTerms() | |
| 64 | + | if hasTerms { | |
| 65 | + | exactRank := 1 | |
| 66 | + | if result.Kind() == ResultKindCommand { | |
| 67 | + | exactRank = 0 | |
| 68 | + | } | |
| 69 | + | best, matched := scoreField(identity, needle, needleRunes, offset, exactRank, 2, 3) | |
| 70 | + | if label != identity { | |
| 71 | + | if candidate, ok := scoreField(label, needle, needleRunes, offset, exactRank, 2, 3); ok && (!matched || candidate.betterThan(best)) { | |
| 72 | + | best, matched = candidate, true | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | if matched { | |
| 76 | + | best.apply(&match) | |
| 77 | + | return match, true | |
| 78 | + | } | |
| 80 | 79 | } | |
| 81 | - | if positions, ok := subsequencePositions([]rune(text), needleRunes); ok { | |
| 82 | - | match.Positions = positions | |
| 83 | - | match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0] | |
| 80 | + | ||
| 81 | + | // Action text remains searchable, but semantic codes and session labels | |
| 82 | + | // outrank matches caused only by a rendered action prefix. | |
| 83 | + | if fallback, ok := scoreField(match.search, needle, needleRunes, 0, 4, 4, 4); ok { | |
| 84 | + | fallback.apply(&match) | |
| 84 | 85 | return match, true | |
| 85 | 86 | } | |
| 86 | 87 | if cmd, ok := result.Command(); ok { | |
| 87 | 88 | if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { | |
| 88 | - | match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0] | |
| 89 | + | match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0] | |
| 89 | 90 | return match, true | |
| 90 | 91 | } | |
| 91 | 92 | } | |
| 92 | 93 | return Match{}, false | |
| 93 | 94 | } | |
| 94 | 95 | ||
| 96 | + | type fieldScore struct { | |
| 97 | + | positions []int | |
| 98 | + | rank int | |
| 99 | + | span int | |
| 100 | + | first int | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) { | |
| 104 | + | text = strings.ToLower(text) | |
| 105 | + | var scored fieldScore | |
| 106 | + | switch { | |
| 107 | + | case text == needle: | |
| 108 | + | scored.positions = rangePositions(utf8.RuneCountInString(text)) | |
| 109 | + | scored.rank = exactRank | |
| 110 | + | case strings.HasPrefix(text, needle): | |
| 111 | + | scored.positions = rangePositions(utf8.RuneCountInString(needle)) | |
| 112 | + | scored.rank = prefixRank | |
| 113 | + | default: | |
| 114 | + | positions, ok := subsequencePositions([]rune(text), needleRunes) | |
| 115 | + | if !ok { | |
| 116 | + | return fieldScore{}, false | |
| 117 | + | } | |
| 118 | + | scored.positions = positions | |
| 119 | + | scored.rank = subsequenceRank | |
| 120 | + | } | |
| 121 | + | scored.span = scored.positions[len(scored.positions)-1] - scored.positions[0] + 1 | |
| 122 | + | scored.first = scored.positions[0] | |
| 123 | + | for i := range scored.positions { | |
| 124 | + | scored.positions[i] += displayOffset | |
| 125 | + | } | |
| 126 | + | return scored, true | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func (s fieldScore) betterThan(other fieldScore) bool { | |
| 130 | + | if s.rank != other.rank { | |
| 131 | + | return s.rank < other.rank | |
| 132 | + | } | |
| 133 | + | if s.span != other.span { | |
| 134 | + | return s.span < other.span | |
| 135 | + | } | |
| 136 | + | return s.first < other.first | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (s fieldScore) apply(match *Match) { | |
| 140 | + | match.Positions = s.positions | |
| 141 | + | match.rank = s.rank | |
| 142 | + | match.span = s.span | |
| 143 | + | match.first = s.first | |
| 144 | + | } | |
| 145 | + | ||
| 95 | 146 | func newMatch(result Result, order int) Match { | |
| 96 | 147 | return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())} | |
| 97 | 148 | } | |
| @@ -59,39 +59,90 @@ func Fuzzy(results []Result, query string) []Match { | |||||
| 59 | 59 | ||||
| 60 | func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) { | 60 | func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) { | ||
| 61 | match := newMatch(result, order) | 61 | match := newMatch(result, order) | ||
| 62 | - | text := match.search | |||
| 63 | 62 | ||||
| 64 | - | // An exact command shortcode always wins over every other match. The | 63 | + | identity, label, offset, hasTerms := result.searchTerms() |
| 65 | - | // remaining results then share exact/prefix/subsequence scoring. | 64 | + | if hasTerms { |
| 66 | - | if result.Kind() == ResultKindCommand && text == needle { | 65 | + | exactRank := 1 |
| 67 | - | match.Positions = rangePositions(utf8.RuneCountInString(text)) | 66 | + | if result.Kind() == ResultKindCommand { |
| 68 | - | match.rank, match.span, match.first = 0, len(match.Positions), 0 | 67 | + | exactRank = 0 |
| 69 | - | return match, true | 68 | + | } |
| 70 | - | } | 69 | + | best, matched := scoreField(identity, needle, needleRunes, offset, exactRank, 2, 3) |
| 71 | - | if text == needle { | 70 | + | if label != identity { |
| 72 | - | match.Positions = rangePositions(utf8.RuneCountInString(text)) | 71 | + | if candidate, ok := scoreField(label, needle, needleRunes, offset, exactRank, 2, 3); ok && (!matched || candidate.betterThan(best)) { |
| 73 | - | match.rank, match.span, match.first = 1, len(match.Positions), 0 | 72 | + | best, matched = candidate, true |
| 74 | - | return match, true | 73 | + | } |
| 75 | - | } | 74 | + | } |
| 76 | - | if strings.HasPrefix(text, needle) { | 75 | + | if matched { |
| 77 | - | match.Positions = rangePositions(utf8.RuneCountInString(needle)) | 76 | + | best.apply(&match) |
| 78 | - | match.rank, match.span, match.first = 2, len(match.Positions), 0 | 77 | + | return match, true |
| 79 | - | return match, true | 78 | + | } |
| 80 | } | 79 | } | ||
| 81 | - | if positions, ok := subsequencePositions([]rune(text), needleRunes); ok { | 80 | + | |
| 82 | - | match.Positions = positions | 81 | + | // Action text remains searchable, but semantic codes and session labels |
| 83 | - | match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0] | 82 | + | // outrank matches caused only by a rendered action prefix. |
| 83 | + | if fallback, ok := scoreField(match.search, needle, needleRunes, 0, 4, 4, 4); ok { | |||
| 84 | + | fallback.apply(&match) | |||
| 84 | return match, true | 85 | return match, true | ||
| 85 | } | 86 | } | ||
| 86 | if cmd, ok := result.Command(); ok { | 87 | if cmd, ok := result.Command(); ok { | ||
| 87 | if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { | 88 | if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), needleRunes); ok { | ||
| 88 | - | match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0] | 89 | + | match.rank, match.span, match.first = 5, positions[len(positions)-1]-positions[0]+1, positions[0] |
| 89 | return match, true | 90 | return match, true | ||
| 90 | } | 91 | } | ||
| 91 | } | 92 | } | ||
| 92 | return Match{}, false | 93 | return Match{}, false | ||
| 93 | } | 94 | } | ||
| 94 | 95 | ||||
| 96 | + | type fieldScore struct { | |||
| 97 | + | positions []int | |||
| 98 | + | rank int | |||
| 99 | + | span int | |||
| 100 | + | first int | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func scoreField(text, needle string, needleRunes []rune, displayOffset, exactRank, prefixRank, subsequenceRank int) (fieldScore, bool) { | |||
| 104 | + | text = strings.ToLower(text) | |||
| 105 | + | var scored fieldScore | |||
| 106 | + | switch { | |||
| 107 | + | case text == needle: | |||
| 108 | + | scored.positions = rangePositions(utf8.RuneCountInString(text)) | |||
| 109 | + | scored.rank = exactRank | |||
| 110 | + | case strings.HasPrefix(text, needle): | |||
| 111 | + | scored.positions = rangePositions(utf8.RuneCountInString(needle)) | |||
| 112 | + | scored.rank = prefixRank | |||
| 113 | + | default: | |||
| 114 | + | positions, ok := subsequencePositions([]rune(text), needleRunes) | |||
| 115 | + | if !ok { | |||
| 116 | + | return fieldScore{}, false | |||
| 117 | + | } | |||
| 118 | + | scored.positions = positions | |||
| 119 | + | scored.rank = subsequenceRank | |||
| 120 | + | } | |||
| 121 | + | scored.span = scored.positions[len(scored.positions)-1] - scored.positions[0] + 1 | |||
| 122 | + | scored.first = scored.positions[0] | |||
| 123 | + | for i := range scored.positions { | |||
| 124 | + | scored.positions[i] += displayOffset | |||
| 125 | + | } | |||
| 126 | + | return scored, true | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func (s fieldScore) betterThan(other fieldScore) bool { | |||
| 130 | + | if s.rank != other.rank { | |||
| 131 | + | return s.rank < other.rank | |||
| 132 | + | } | |||
| 133 | + | if s.span != other.span { | |||
| 134 | + | return s.span < other.span | |||
| 135 | + | } | |||
| 136 | + | return s.first < other.first | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (s fieldScore) apply(match *Match) { | |||
| 140 | + | match.Positions = s.positions | |||
| 141 | + | match.rank = s.rank | |||
| 142 | + | match.span = s.span | |||
| 143 | + | match.first = s.first | |||
| 144 | + | } | |||
| 145 | + | ||||
| 95 | func newMatch(result Result, order int) Match { | 146 | func newMatch(result Result, order int) Match { | ||
| 96 | return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())} | 147 | return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())} | ||
| 97 | } | 148 | } | ||
M internal/usecase/palette/fuzzy_test.go +66 -9
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/protocol" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| @@ -78,6 +79,62 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |||
| 78 | 79 | require.Equal(t, []string{"B", "A"}, codes(matches)) | |
| 79 | 80 | } | |
| 80 | 81 | ||
| 82 | + | func TestFuzzyRanksExactSessionNameAheadOfPrefix(t *testing.T) { | |
| 83 | + | tests := []struct { | |
| 84 | + | name string | |
| 85 | + | results []Result | |
| 86 | + | wantFirst string | |
| 87 | + | offset int | |
| 88 | + | }{ | |
| 89 | + | { | |
| 90 | + | name: "active session", | |
| 91 | + | results: []Result{ | |
| 92 | + | NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"), | |
| 93 | + | NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"), | |
| 94 | + | }, | |
| 95 | + | wantFirst: "Switch to session vev@arch", | |
| 96 | + | offset: 18, | |
| 97 | + | }, | |
| 98 | + | { | |
| 99 | + | name: "stopped session", | |
| 100 | + | results: []Result{ | |
| 101 | + | NewStoppedSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"), | |
| 102 | + | NewStoppedSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"), | |
| 103 | + | }, | |
| 104 | + | wantFirst: "Resume session vev@arch", | |
| 105 | + | offset: 15, | |
| 106 | + | }, | |
| 107 | + | { | |
| 108 | + | name: "remote catalog session", | |
| 109 | + | results: []Result{ | |
| 110 | + | NewRemoteSessionResult(domain.RemoteSessionKey{Host: "arch", Name: "vev-vt", DisplayOrigin: "arch"}, domain.RemoteSessionTarget{}, ""), | |
| 111 | + | NewRemoteSessionResult(domain.RemoteSessionKey{Host: "arch", Name: "vev", DisplayOrigin: "arch"}, domain.RemoteSessionTarget{}, ""), | |
| 112 | + | }, | |
| 113 | + | wantFirst: "Switch to session vev@arch", | |
| 114 | + | offset: 18, | |
| 115 | + | }, | |
| 116 | + | { | |
| 117 | + | name: "recent route", | |
| 118 | + | results: []Result{ | |
| 119 | + | NewRecentRouteResult("vev-vt", "vev-vt@arch", protocol.RouteNavigationAction{Key: 1}), | |
| 120 | + | NewRecentRouteResult("vev", "vev@arch", protocol.RouteNavigationAction{Key: 2}), | |
| 121 | + | }, | |
| 122 | + | wantFirst: "Switch to session vev@arch", | |
| 123 | + | offset: 18, | |
| 124 | + | }, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | for _, tt := range tests { | |
| 128 | + | t.Run(tt.name, func(t *testing.T) { | |
| 129 | + | matches := Fuzzy(tt.results, "vev") | |
| 130 | + | ||
| 131 | + | require.Len(t, matches, 2) | |
| 132 | + | require.Equal(t, tt.wantFirst, matches[0].Result.DisplayText()) | |
| 133 | + | require.Equal(t, []int{tt.offset, tt.offset + 1, tt.offset + 2}, matches[0].Positions) | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 81 | 138 | func TestFuzzyOrdersMixedResults(t *testing.T) { | |
| 82 | 139 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | |
| 83 | 140 | tests := []struct { | |
| @@ -90,7 +147,7 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||
| 90 | 147 | wantActiveTargets map[int]protocol.ExactSessionTarget | |
| 91 | 148 | }{ | |
| 92 | 149 | { | |
| 93 | - | name: "command shortcode precedes sessions and description matches", | |
| 150 | + | name: "command shortcode precedes exact session names and broader matches", | |
| 94 | 151 | results: []Result{ | |
| 95 | 152 | NewStoppedSessionResult(testExactTarget("work", 1), created), | |
| 96 | 153 | NewCommandResult(cmd("WORK", "", "Create a workspace")), | |
| @@ -99,9 +156,9 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||
| 99 | 156 | NewCommandResult(cmd("ZZZ", "", "work tools")), | |
| 100 | 157 | }, | |
| 101 | 158 | query: "work", | |
| 102 | - | wantText: []string{"WORK", "Resume session work", "WQORRK", "Switch to session work", "ZZZ"}, | |
| 103 | - | wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindCommand, ResultKindActiveSession, ResultKindCommand}, | |
| 104 | - | wantPositions: [][]int{{0, 1, 2, 3}, {15, 16, 17, 18}, {0, 2, 3, 5}, {1, 8, 20, 21}, nil}, | |
| 159 | + | wantText: []string{"WORK", "Switch to session work", "Resume session work", "WQORRK", "ZZZ"}, | |
| 160 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand}, | |
| 161 | + | wantPositions: [][]int{{0, 1, 2, 3}, {18, 19, 20, 21}, {15, 16, 17, 18}, {0, 2, 3, 5}, nil}, | |
| 105 | 162 | }, | |
| 106 | 163 | { | |
| 107 | 164 | name: "equivalent sessions sort by normalized text", | |
| @@ -115,7 +172,7 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||
| 115 | 172 | wantPositions: [][]int{{15}, {15}}, | |
| 116 | 173 | }, | |
| 117 | 174 | { | |
| 118 | - | name: "display prefix positions sort before later active names", | |
| 175 | + | name: "semantic session prefixes sort by kind then normalized label", | |
| 119 | 176 | results: []Result{ | |
| 120 | 177 | NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}), | |
| 121 | 178 | NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}), | |
| @@ -125,11 +182,11 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||
| 125 | 182 | NewCommandResult(cmd("AX", "", "")), | |
| 126 | 183 | }, | |
| 127 | 184 | query: "a", | |
| 128 | - | wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"}, | |
| 129 | - | wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession}, | |
| 130 | - | wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}}, | |
| 185 | + | wantText: []string{"AX", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu", "Resume session aAlpha", "Resume session aBravo"}, | |
| 186 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession}, | |
| 187 | + | wantPositions: [][]int{{0}, {18}, {18}, {18}, {15}, {15}}, | |
| 131 | 188 | wantActiveTargets: map[int]protocol.ExactSessionTarget{ | |
| 132 | - | 3: testExactTarget("aEcho", 4), 4: testExactTarget("aEcho", 5), 5: testExactTarget("aZulu", 3), | |
| 189 | + | 1: testExactTarget("aEcho", 4), 2: testExactTarget("aEcho", 5), 3: testExactTarget("aZulu", 3), | |
| 133 | 190 | }, | |
| 134 | 191 | }, | |
| 135 | 192 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 5 | "time" | 5 | "time" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | "github.com/bnema/vev/internal/protocol" | 8 | "github.com/bnema/vev/internal/protocol" | ||
| 8 | "github.com/bnema/vev/internal/usecase/command" | 9 | "github.com/bnema/vev/internal/usecase/command" | ||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| @@ -78,6 +79,62 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |||||
| 78 | require.Equal(t, []string{"B", "A"}, codes(matches)) | 79 | require.Equal(t, []string{"B", "A"}, codes(matches)) | ||
| 79 | } | 80 | } | ||
| 80 | 81 | ||||
| 82 | + | func TestFuzzyRanksExactSessionNameAheadOfPrefix(t *testing.T) { | |||
| 83 | + | tests := []struct { | |||
| 84 | + | name string | |||
| 85 | + | results []Result | |||
| 86 | + | wantFirst string | |||
| 87 | + | offset int | |||
| 88 | + | }{ | |||
| 89 | + | { | |||
| 90 | + | name: "active session", | |||
| 91 | + | results: []Result{ | |||
| 92 | + | NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"), | |||
| 93 | + | NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"), | |||
| 94 | + | }, | |||
| 95 | + | wantFirst: "Switch to session vev@arch", | |||
| 96 | + | offset: 18, | |||
| 97 | + | }, | |||
| 98 | + | { | |||
| 99 | + | name: "stopped session", | |||
| 100 | + | results: []Result{ | |||
| 101 | + | NewStoppedSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"), | |||
| 102 | + | NewStoppedSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"), | |||
| 103 | + | }, | |||
| 104 | + | wantFirst: "Resume session vev@arch", | |||
| 105 | + | offset: 15, | |||
| 106 | + | }, | |||
| 107 | + | { | |||
| 108 | + | name: "remote catalog session", | |||
| 109 | + | results: []Result{ | |||
| 110 | + | NewRemoteSessionResult(domain.RemoteSessionKey{Host: "arch", Name: "vev-vt", DisplayOrigin: "arch"}, domain.RemoteSessionTarget{}, ""), | |||
| 111 | + | NewRemoteSessionResult(domain.RemoteSessionKey{Host: "arch", Name: "vev", DisplayOrigin: "arch"}, domain.RemoteSessionTarget{}, ""), | |||
| 112 | + | }, | |||
| 113 | + | wantFirst: "Switch to session vev@arch", | |||
| 114 | + | offset: 18, | |||
| 115 | + | }, | |||
| 116 | + | { | |||
| 117 | + | name: "recent route", | |||
| 118 | + | results: []Result{ | |||
| 119 | + | NewRecentRouteResult("vev-vt", "vev-vt@arch", protocol.RouteNavigationAction{Key: 1}), | |||
| 120 | + | NewRecentRouteResult("vev", "vev@arch", protocol.RouteNavigationAction{Key: 2}), | |||
| 121 | + | }, | |||
| 122 | + | wantFirst: "Switch to session vev@arch", | |||
| 123 | + | offset: 18, | |||
| 124 | + | }, | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | for _, tt := range tests { | |||
| 128 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 129 | + | matches := Fuzzy(tt.results, "vev") | |||
| 130 | + | ||||
| 131 | + | require.Len(t, matches, 2) | |||
| 132 | + | require.Equal(t, tt.wantFirst, matches[0].Result.DisplayText()) | |||
| 133 | + | require.Equal(t, []int{tt.offset, tt.offset + 1, tt.offset + 2}, matches[0].Positions) | |||
| 134 | + | }) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 81 | func TestFuzzyOrdersMixedResults(t *testing.T) { | 138 | func TestFuzzyOrdersMixedResults(t *testing.T) { | ||
| 82 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | 139 | created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC) | ||
| 83 | tests := []struct { | 140 | tests := []struct { | ||
| @@ -90,7 +147,7 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||||
| 90 | wantActiveTargets map[int]protocol.ExactSessionTarget | 147 | wantActiveTargets map[int]protocol.ExactSessionTarget | ||
| 91 | }{ | 148 | }{ | ||
| 92 | { | 149 | { | ||
| 93 | - | name: "command shortcode precedes sessions and description matches", | 150 | + | name: "command shortcode precedes exact session names and broader matches", |
| 94 | results: []Result{ | 151 | results: []Result{ | ||
| 95 | NewStoppedSessionResult(testExactTarget("work", 1), created), | 152 | NewStoppedSessionResult(testExactTarget("work", 1), created), | ||
| 96 | NewCommandResult(cmd("WORK", "", "Create a workspace")), | 153 | NewCommandResult(cmd("WORK", "", "Create a workspace")), | ||
| @@ -99,9 +156,9 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||||
| 99 | NewCommandResult(cmd("ZZZ", "", "work tools")), | 156 | NewCommandResult(cmd("ZZZ", "", "work tools")), | ||
| 100 | }, | 157 | }, | ||
| 101 | query: "work", | 158 | query: "work", | ||
| 102 | - | wantText: []string{"WORK", "Resume session work", "WQORRK", "Switch to session work", "ZZZ"}, | 159 | + | wantText: []string{"WORK", "Switch to session work", "Resume session work", "WQORRK", "ZZZ"}, |
| 103 | - | wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindCommand, ResultKindActiveSession, ResultKindCommand}, | 160 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand}, |
| 104 | - | wantPositions: [][]int{{0, 1, 2, 3}, {15, 16, 17, 18}, {0, 2, 3, 5}, {1, 8, 20, 21}, nil}, | 161 | + | wantPositions: [][]int{{0, 1, 2, 3}, {18, 19, 20, 21}, {15, 16, 17, 18}, {0, 2, 3, 5}, nil}, |
| 105 | }, | 162 | }, | ||
| 106 | { | 163 | { | ||
| 107 | name: "equivalent sessions sort by normalized text", | 164 | name: "equivalent sessions sort by normalized text", | ||
| @@ -115,7 +172,7 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||||
| 115 | wantPositions: [][]int{{15}, {15}}, | 172 | wantPositions: [][]int{{15}, {15}}, | ||
| 116 | }, | 173 | }, | ||
| 117 | { | 174 | { | ||
| 118 | - | name: "display prefix positions sort before later active names", | 175 | + | name: "semantic session prefixes sort by kind then normalized label", |
| 119 | results: []Result{ | 176 | results: []Result{ | ||
| 120 | NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}), | 177 | NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}), | ||
| 121 | NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}), | 178 | NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}), | ||
| @@ -125,11 +182,11 @@ func TestFuzzyOrdersMixedResults(t *testing.T) { | |||||
| 125 | NewCommandResult(cmd("AX", "", "")), | 182 | NewCommandResult(cmd("AX", "", "")), | ||
| 126 | }, | 183 | }, | ||
| 127 | query: "a", | 184 | query: "a", | ||
| 128 | - | wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"}, | 185 | + | wantText: []string{"AX", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu", "Resume session aAlpha", "Resume session aBravo"}, |
| 129 | - | wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession}, | 186 | + | wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession}, |
| 130 | - | wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}}, | 187 | + | wantPositions: [][]int{{0}, {18}, {18}, {18}, {15}, {15}}, |
| 131 | wantActiveTargets: map[int]protocol.ExactSessionTarget{ | 188 | wantActiveTargets: map[int]protocol.ExactSessionTarget{ | ||
| 132 | - | 3: testExactTarget("aEcho", 4), 4: testExactTarget("aEcho", 5), 5: testExactTarget("aZulu", 3), | 189 | + | 1: testExactTarget("aEcho", 4), 2: testExactTarget("aEcho", 5), 3: testExactTarget("aZulu", 3), |
| 133 | }, | 190 | }, | ||
| 134 | }, | 191 | }, | ||
| 135 | } | 192 | } | ||
M internal/usecase/palette/model_test.go +16 -0
| @@ -199,6 +199,22 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | |||
| 199 | 199 | require.Len(t, m.Matches(), 3) | |
| 200 | 200 | } | |
| 201 | 201 | ||
| 202 | + | func TestModelSelectsExactSessionNameAheadOfPrefix(t *testing.T) { | |
| 203 | + | m := New([]Result{ | |
| 204 | + | NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"), | |
| 205 | + | NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"), | |
| 206 | + | }) | |
| 207 | + | for _, r := range "vev" { | |
| 208 | + | m.Insert(r) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | selected, ok := m.Selected() | |
| 212 | + | require.True(t, ok) | |
| 213 | + | name, ok := selected.SessionName() | |
| 214 | + | require.True(t, ok) | |
| 215 | + | require.Equal(t, "vev", name) | |
| 216 | + | } | |
| 217 | + | ||
| 202 | 218 | func TestModelNoMatchesClearsSelection(t *testing.T) { | |
| 203 | 219 | m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")})) | |
| 204 | 220 | m.Insert('z') | |
| @@ -199,6 +199,22 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | |||||
| 199 | require.Len(t, m.Matches(), 3) | 199 | require.Len(t, m.Matches(), 3) | ||
| 200 | } | 200 | } | ||
| 201 | 201 | ||||
| 202 | + | func TestModelSelectsExactSessionNameAheadOfPrefix(t *testing.T) { | |||
| 203 | + | m := New([]Result{ | |||
| 204 | + | NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"), | |||
| 205 | + | NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"), | |||
| 206 | + | }) | |||
| 207 | + | for _, r := range "vev" { | |||
| 208 | + | m.Insert(r) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | selected, ok := m.Selected() | |||
| 212 | + | require.True(t, ok) | |||
| 213 | + | name, ok := selected.SessionName() | |||
| 214 | + | require.True(t, ok) | |||
| 215 | + | require.Equal(t, "vev", name) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 202 | func TestModelNoMatchesClearsSelection(t *testing.T) { | 218 | func TestModelNoMatchesClearsSelection(t *testing.T) { | ||
| 203 | m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")})) | 219 | m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")})) | ||
| 204 | m.Insert('z') | 220 | m.Insert('z') | ||
M internal/usecase/palette/result.go +21 -2
| @@ -2,6 +2,7 @@ package palette | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "time" | |
| 5 | + | "unicode/utf8" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/protocol" | |
| @@ -53,6 +54,7 @@ type remoteSessionPayload struct { | |||
| 53 | 54 | } | |
| 54 | 55 | ||
| 55 | 56 | type routePayload struct { | |
| 57 | + | name string | |
| 56 | 58 | label string | |
| 57 | 59 | action protocol.RouteNavigationAction | |
| 58 | 60 | } | |
| @@ -118,8 +120,8 @@ func NewRemoteSessionResult(key domain.RemoteSessionKey, target domain.RemoteSes | |||
| 118 | 120 | } | |
| 119 | 121 | ||
| 120 | 122 | // NewRecentRouteResult creates an immutable client-ledger route target. | |
| 121 | - | func NewRecentRouteResult(label string, action protocol.RouteNavigationAction) Result { | |
| 122 | - | return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}} | |
| 123 | + | func NewRecentRouteResult(name, label string, action protocol.RouteNavigationAction) Result { | |
| 124 | + | return Result{kind: ResultKindRecentRoute, route: routePayload{name: name, label: label, action: action}} | |
| 123 | 125 | } | |
| 124 | 126 | ||
| 125 | 127 | // NewCreateSessionDestination creates an immutable destination template. The | |
| @@ -202,6 +204,23 @@ func (r Result) SearchText() string { | |||
| 202 | 204 | return r.DisplayText() | |
| 203 | 205 | } | |
| 204 | 206 | ||
| 207 | + | // searchTerms returns the user-facing identity and qualified label without the | |
| 208 | + | // action prefix. The offset maps term rune positions back into DisplayText. | |
| 209 | + | func (r Result) searchTerms() (identity, label string, offset int, ok bool) { | |
| 210 | + | switch r.kind { | |
| 211 | + | case ResultKindCommand: | |
| 212 | + | return r.command.Code, r.command.Code, 0, true | |
| 213 | + | case ResultKindActiveSession, ResultKindStoppedSession: | |
| 214 | + | return r.session.target.SessionName, r.session.display, utf8.RuneCountInString(r.sessionDisplayPrefix()), true | |
| 215 | + | case ResultKindRemoteSession: | |
| 216 | + | return r.remoteSession.key.Name, r.remoteSession.key.Display(), utf8.RuneCountInString(activeSessionDisplayPrefix), true | |
| 217 | + | case ResultKindRecentRoute: | |
| 218 | + | return r.route.name, r.route.label, utf8.RuneCountInString(activeSessionDisplayPrefix), true | |
| 219 | + | default: | |
| 220 | + | return "", "", 0, false | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 205 | 224 | // Command returns the command payload only for command results. | |
| 206 | 225 | func (r Result) Command() (command.Command, bool) { | |
| 207 | 226 | return r.command, r.kind == ResultKindCommand | |
| @@ -2,6 +2,7 @@ package palette | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "time" | 4 | "time" | ||
| 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/protocol" | 8 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -53,6 +54,7 @@ type remoteSessionPayload struct { | |||||
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| 55 | type routePayload struct { | 56 | type routePayload struct { | ||
| 57 | + | name string | |||
| 56 | label string | 58 | label string | ||
| 57 | action protocol.RouteNavigationAction | 59 | action protocol.RouteNavigationAction | ||
| 58 | } | 60 | } | ||
| @@ -118,8 +120,8 @@ func NewRemoteSessionResult(key domain.RemoteSessionKey, target domain.RemoteSes | |||||
| 118 | } | 120 | } | ||
| 119 | 121 | ||||
| 120 | // NewRecentRouteResult creates an immutable client-ledger route target. | 122 | // NewRecentRouteResult creates an immutable client-ledger route target. | ||
| 121 | - | func NewRecentRouteResult(label string, action protocol.RouteNavigationAction) Result { | 123 | + | func NewRecentRouteResult(name, label string, action protocol.RouteNavigationAction) Result { |
| 122 | - | return Result{kind: ResultKindRecentRoute, route: routePayload{label: label, action: action}} | 124 | + | return Result{kind: ResultKindRecentRoute, route: routePayload{name: name, label: label, action: action}} |
| 123 | } | 125 | } | ||
| 124 | 126 | ||||
| 125 | // NewCreateSessionDestination creates an immutable destination template. The | 127 | // NewCreateSessionDestination creates an immutable destination template. The | ||
| @@ -202,6 +204,23 @@ func (r Result) SearchText() string { | |||||
| 202 | return r.DisplayText() | 204 | return r.DisplayText() | ||
| 203 | } | 205 | } | ||
| 204 | 206 | ||||
| 207 | + | // searchTerms returns the user-facing identity and qualified label without the | |||
| 208 | + | // action prefix. The offset maps term rune positions back into DisplayText. | |||
| 209 | + | func (r Result) searchTerms() (identity, label string, offset int, ok bool) { | |||
| 210 | + | switch r.kind { | |||
| 211 | + | case ResultKindCommand: | |||
| 212 | + | return r.command.Code, r.command.Code, 0, true | |||
| 213 | + | case ResultKindActiveSession, ResultKindStoppedSession: | |||
| 214 | + | return r.session.target.SessionName, r.session.display, utf8.RuneCountInString(r.sessionDisplayPrefix()), true | |||
| 215 | + | case ResultKindRemoteSession: | |||
| 216 | + | return r.remoteSession.key.Name, r.remoteSession.key.Display(), utf8.RuneCountInString(activeSessionDisplayPrefix), true | |||
| 217 | + | case ResultKindRecentRoute: | |||
| 218 | + | return r.route.name, r.route.label, utf8.RuneCountInString(activeSessionDisplayPrefix), true | |||
| 219 | + | default: | |||
| 220 | + | return "", "", 0, false | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 205 | // Command returns the command payload only for command results. | 224 | // Command returns the command payload only for command results. | ||
| 206 | func (r Result) Command() (command.Command, bool) { | 225 | func (r Result) Command() (command.Command, bool) { | ||
| 207 | return r.command, r.kind == ResultKindCommand | 226 | return r.command, r.kind == ResultKindCommand | ||
M internal/usecase/palette/result_test.go +3 -3
| @@ -74,7 +74,7 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||
| 74 | 74 | ||
| 75 | 75 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | |
| 76 | 76 | action := protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: 7, Generation: 3} | |
| 77 | - | result := NewRecentRouteResult("logs@edge", action) | |
| 77 | + | result := NewRecentRouteResult("logs", "logs@edge", action) | |
| 78 | 78 | ||
| 79 | 79 | require.Equal(t, ResultKindRecentRoute, result.Kind()) | |
| 80 | 80 | require.Equal(t, "Switch to session logs@edge", result.DisplayText()) | |
| @@ -86,7 +86,7 @@ func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | |||
| 86 | 86 | require.False(t, ok) | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | - | func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | |
| 89 | + | func TestSessionResultsSearchSemanticNamesAndDisplayedActionText(t *testing.T) { | |
| 90 | 90 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | |
| 91 | 91 | tests := []struct { | |
| 92 | 92 | name string | |
| @@ -95,7 +95,7 @@ func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | |||
| 95 | 95 | positions []int | |
| 96 | 96 | }{ | |
| 97 | 97 | {name: "active prefix", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, | |
| 98 | - | {name: "active name", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "work", positions: []int{1, 8, 20, 21}}, | |
| 98 | + | {name: "active name", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "work", positions: []int{18, 19, 20, 21}}, | |
| 99 | 99 | {name: "stopped prefix", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, | |
| 100 | 100 | {name: "stopped name", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, | |
| 101 | 101 | } | |
| @@ -74,7 +74,7 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) { | |||||
| 74 | 74 | ||||
| 75 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | 75 | func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | ||
| 76 | action := protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: 7, Generation: 3} | 76 | action := protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: 7, Generation: 3} | ||
| 77 | - | result := NewRecentRouteResult("logs@edge", action) | 77 | + | result := NewRecentRouteResult("logs", "logs@edge", action) |
| 78 | 78 | ||||
| 79 | require.Equal(t, ResultKindRecentRoute, result.Kind()) | 79 | require.Equal(t, ResultKindRecentRoute, result.Kind()) | ||
| 80 | require.Equal(t, "Switch to session logs@edge", result.DisplayText()) | 80 | require.Equal(t, "Switch to session logs@edge", result.DisplayText()) | ||
| @@ -86,7 +86,7 @@ func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) { | |||||
| 86 | require.False(t, ok) | 86 | require.False(t, ok) | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | - | func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | 89 | + | func TestSessionResultsSearchSemanticNamesAndDisplayedActionText(t *testing.T) { |
| 90 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | 90 | created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC) | ||
| 91 | tests := []struct { | 91 | tests := []struct { | ||
| 92 | name string | 92 | name string | ||
| @@ -95,7 +95,7 @@ func TestSessionResultsSearchDisplayedActionText(t *testing.T) { | |||||
| 95 | positions []int | 95 | positions []int | ||
| 96 | }{ | 96 | }{ | ||
| 97 | {name: "active prefix", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, | 97 | {name: "active prefix", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "switch", positions: []int{0, 1, 2, 3, 4, 5}}, | ||
| 98 | - | {name: "active name", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "work", positions: []int{1, 8, 20, 21}}, | 98 | + | {name: "active name", result: NewActiveSessionResult(testExactTarget("work", 1), created), query: "work", positions: []int{18, 19, 20, 21}}, |
| 99 | {name: "stopped prefix", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, | 99 | {name: "stopped prefix", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}}, | ||
| 100 | {name: "stopped name", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, | 100 | {name: "stopped name", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}}, | ||
| 101 | } | 101 | } | ||