detached b09c5d7

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 UnverifiedB5690EEEBB952194 The 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
267267 if _, exists := represented[identity]; exists && paletteRouteRepresentsDaemonSession(entry, daemonDisplayOrigin) {
268268 continue
269269 }
270- results = append(results, palette.NewRecentRouteResult(label, protocol.RouteNavigationAction{
270+ results = append(results, palette.NewRecentRouteResult(entry.Target.SessionName, label, protocol.RouteNavigationAction{
271271 SnapshotGeneration: routeSnapshot.Generation,
272272 Key: entry.Key,
273273 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 continue268 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 {
5959
6060 func score(result Result, needle string, needleRunes []rune, order int) (Match, bool) {
6161 match := newMatch(result, order)
62- text := match.search
6362
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+ }
8079 }
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)
8485 return match, true
8586 }
8687 if cmd, ok := result.Command(); ok {
8788 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]
8990 return match, true
9091 }
9192 }
9293 return Match{}, false
9394 }
9495
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+
95146 func newMatch(result Result, order int) Match {
96147 return Match{Result: result, order: order, search: strings.ToLower(result.SearchText())}
97148 }
@@ -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. The63+ 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), 067+ exactRank = 0
69- return match, true68+ }
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), 072+ best, matched = candidate, true
74- return match, true73+ }
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), 077+ return match, true
79- return match, true78+ }
80 }79 }
81- if positions, ok := subsequencePositions([]rune(text), needleRunes); ok {80+
82- match.Positions = positions81+ // 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, true85 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, true90 return match, true
90 }91 }
91 }92 }
92 return Match{}, false93 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 (
44 "testing"
55 "time"
66
7+ "github.com/bnema/vev/internal/domain"
78 "github.com/bnema/vev/internal/protocol"
89 "github.com/bnema/vev/internal/usecase/command"
910 "github.com/stretchr/testify/require"
@@ -78,6 +79,62 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
7879 require.Equal(t, []string{"B", "A"}, codes(matches))
7980 }
8081
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+
81138 func TestFuzzyOrdersMixedResults(t *testing.T) {
82139 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
83140 tests := []struct {
@@ -90,7 +147,7 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
90147 wantActiveTargets map[int]protocol.ExactSessionTarget
91148 }{
92149 {
93- name: "command shortcode precedes sessions and description matches",
150+ name: "command shortcode precedes exact session names and broader matches",
94151 results: []Result{
95152 NewStoppedSessionResult(testExactTarget("work", 1), created),
96153 NewCommandResult(cmd("WORK", "", "Create a workspace")),
@@ -99,9 +156,9 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
99156 NewCommandResult(cmd("ZZZ", "", "work tools")),
100157 },
101158 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},
105162 },
106163 {
107164 name: "equivalent sessions sort by normalized text",
@@ -115,7 +172,7 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
115172 wantPositions: [][]int{{15}, {15}},
116173 },
117174 {
118- name: "display prefix positions sort before later active names",
175+ name: "semantic session prefixes sort by kind then normalized label",
119176 results: []Result{
120177 NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}),
121178 NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}),
@@ -125,11 +182,11 @@ func TestFuzzyOrdersMixedResults(t *testing.T) {
125182 NewCommandResult(cmd("AX", "", "")),
126183 },
127184 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}},
131188 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),
133190 },
134191 },
135192 }
@@ -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.ExactSessionTarget147 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) {
199199 require.Len(t, m.Matches(), 3)
200200 }
201201
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+
202218 func TestModelNoMatchesClearsSelection(t *testing.T) {
203219 m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
204220 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
22
33 import (
44 "time"
5+ "unicode/utf8"
56
67 "github.com/bnema/vev/internal/domain"
78 "github.com/bnema/vev/internal/protocol"
@@ -53,6 +54,7 @@ type remoteSessionPayload struct {
5354 }
5455
5556 type routePayload struct {
57+ name string
5658 label string
5759 action protocol.RouteNavigationAction
5860 }
@@ -118,8 +120,8 @@ func NewRemoteSessionResult(key domain.RemoteSessionKey, target domain.RemoteSes
118120 }
119121
120122 // 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}}
123125 }
124126
125127 // NewCreateSessionDestination creates an immutable destination template. The
@@ -202,6 +204,23 @@ func (r Result) SearchText() string {
202204 return r.DisplayText()
203205 }
204206
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+
205224 // Command returns the command payload only for command results.
206225 func (r Result) Command() (command.Command, bool) {
207226 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 string58 label string
57 action protocol.RouteNavigationAction59 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. The127 // 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 == ResultKindCommand226 return r.command, r.kind == ResultKindCommand
M internal/usecase/palette/result_test.go +3 -3
@@ -74,7 +74,7 @@ func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
7474
7575 func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) {
7676 action := protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: 7, Generation: 3}
77- result := NewRecentRouteResult("logs@edge", action)
77+ result := NewRecentRouteResult("logs", "logs@edge", action)
7878
7979 require.Equal(t, ResultKindRecentRoute, result.Kind())
8080 require.Equal(t, "Switch to session logs@edge", result.DisplayText())
@@ -86,7 +86,7 @@ func TestRecentRouteResultCarriesExactNavigationAction(t *testing.T) {
8686 require.False(t, ok)
8787 }
8888
89-func TestSessionResultsSearchDisplayedActionText(t *testing.T) {
89+func TestSessionResultsSearchSemanticNamesAndDisplayedActionText(t *testing.T) {
9090 created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
9191 tests := []struct {
9292 name string
@@ -95,7 +95,7 @@ func TestSessionResultsSearchDisplayedActionText(t *testing.T) {
9595 positions []int
9696 }{
9797 {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}},
9999 {name: "stopped prefix", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "resume", positions: []int{0, 1, 2, 3, 4, 5}},
100100 {name: "stopped name", result: NewStoppedSessionResult(testExactTarget("archive", 2), created), query: "archive", positions: []int{15, 16, 17, 18, 19, 20, 21}},
101101 }
@@ -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 string92 name string
@@ -95,7 +95,7 @@ func TestSessionResultsSearchDisplayedActionText(t *testing.T) {
95 positions []int95 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 }

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