detached 0400d88

feat(palette): add typed session results

(cherry picked from commit d32b6a7f019e38809438c3226b6062dc5c2bf117)
commit
0400d88a4d197eed477b8f916a81e47031f569c1
author
brice · · 2026-07-15 06:17 UTC
committer
brice · · 2026-07-15 07:47 UTC
parent
bc0f858
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +410 -90
M internal/usecase/palette/fuzzy.go +91 -27
@@ -7,7 +7,10 @@ import (
77 "github.com/bnema/vev/internal/usecase/command"
88 )
99
10+// Match is a fuzzy-matched immutable palette result. Command is retained for
11+// compatibility with command-only callers; session results leave it empty.
1012 type Match struct {
13+ Result Result
1114 Command command.Command
1215 Positions []int
1316 rank int
@@ -16,18 +19,39 @@ type Match struct {
1619 order int
1720 }
1821
19-func Fuzzy(commands []command.Command, query string) []Match {
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+ }
34+ return fuzzyResults(results, query)
35+}
36+
37+func fuzzyResults(results []Result, query string) []Match {
2038 if query == "" {
21- out := make([]Match, len(commands))
22- for i, cmd := range commands {
23- out[i] = Match{Command: cmd, order: i}
39+ out := make([]Match, 0, len(results))
40+ for i, result := range results {
41+ if result != nil {
42+ out = append(out, newMatch(result, i))
43+ }
2444 }
2545 return out
2646 }
47+
2748 var out []Match
28- for i, cmd := range commands {
29- if m, ok := score(cmd, query, i); ok {
30- out = append(out, m)
49+ for i, result := range results {
50+ if result == nil {
51+ continue
52+ }
53+ if match, ok := score(result, query, i); ok {
54+ out = append(out, match)
3155 }
3256 }
3357 sort.SliceStable(out, func(i, j int) bool {
@@ -41,41 +65,81 @@ func Fuzzy(commands []command.Command, query string) []Match {
4165 if a.first != b.first {
4266 return a.first < b.first
4367 }
44- if a.Command.Code != b.Command.Code {
45- return a.Command.Code < b.Command.Code
68+ if a.Result.Kind() != b.Result.Kind() {
69+ return a.Result.Kind() < b.Result.Kind()
70+ }
71+ if aText, bText := normalizedSearchText(a.Result), normalizedSearchText(b.Result); aText != bText {
72+ return aText < bText
4673 }
4774 return a.order < b.order
4875 })
4976 return out
5077 }
5178
52-func score(cmd command.Command, query string, order int) (Match, bool) {
53- code := []rune(cmd.Code)
54- q := []rune(query)
55- codeLower := strings.ToLower(cmd.Code)
56- qLower := strings.ToLower(query)
57- if codeLower == qLower {
58- return Match{Command: cmd, Positions: rangePositions(len(code)), rank: 0, span: len(code), first: 0, order: order}, true
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)
83+ match := newMatch(result, order)
84+
85+ // An exact command shortcode always wins over every other match. The
86+ // remaining results then share exact/prefix/subsequence scoring.
87+ if result.Kind() == ResultKindCommand && text == needle {
88+ match.Positions = rangePositions(len(textRunes))
89+ match.rank, match.span, match.first = 0, len(textRunes), 0
90+ return match, true
5991 }
60- if strings.HasPrefix(codeLower, qLower) {
61- return Match{Command: cmd, Positions: rangePositions(len(q)), rank: 1, span: len(q), first: 0, order: order}, true
92+ if text == needle {
93+ match.Positions = rangePositions(len(textRunes))
94+ match.rank, match.span, match.first = 1, len(textRunes), 0
95+ return match, true
6296 }
63- if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {
64- return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
97+ if strings.HasPrefix(text, needle) {
98+ match.Positions = rangePositions(len(queryRunes))
99+ match.rank, match.span, match.first = 2, len(queryRunes), 0
100+ return match, true
65101 }
66- text := strings.ToLower(cmd.Desc)
67- if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {
68- return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true
102+ if positions, ok := subsequencePositions(textRunes, queryRunes); ok {
103+ match.Positions = positions
104+ match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0]
105+ return match, true
106+ }
107+ if cmd, ok := resultCommand(result); ok {
108+ if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), queryRunes); ok {
109+ match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0]
110+ return match, true
111+ }
69112 }
70113 return Match{}, false
71114 }
72115
116+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+ }
135+}
136+
73137 func rangePositions(n int) []int {
74- p := make([]int, n)
75- for i := range p {
76- p[i] = i
138+ positions := make([]int, n)
139+ for i := range positions {
140+ positions[i] = i
77141 }
78- return p
142+ return positions
79143 }
80144
81145 func subsequencePositions(haystack, needle []rune) ([]int, bool) {
@@ -7,7 +7,10 @@ import (
7 "github.com/bnema/vev/internal/usecase/command"7 "github.com/bnema/vev/internal/usecase/command"
8 )8 )
9 9
10+// Match is a fuzzy-matched immutable palette result. Command is retained for
11+// compatibility with command-only callers; session results leave it empty.
10 type Match struct {12 type Match struct {
13+ Result Result
11 Command command.Command14 Command command.Command
12 Positions []int15 Positions []int
13 rank int16 rank int
@@ -16,18 +19,39 @@ type Match struct {
16 order int19 order int
17 }20 }
18 21
19-func Fuzzy(commands []command.Command, query string) []Match {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+ }
34+ return fuzzyResults(results, query)
35+}
36+
37+func fuzzyResults(results []Result, query string) []Match {
20 if query == "" {38 if query == "" {
21- out := make([]Match, len(commands))39+ out := make([]Match, 0, len(results))
22- for i, cmd := range commands {40+ for i, result := range results {
23- out[i] = Match{Command: cmd, order: i}41+ if result != nil {
42+ out = append(out, newMatch(result, i))
43+ }
24 }44 }
25 return out45 return out
26 }46 }
47+
27 var out []Match48 var out []Match
28- for i, cmd := range commands {49+ for i, result := range results {
29- if m, ok := score(cmd, query, i); ok {50+ if result == nil {
30- out = append(out, m)51+ continue
52+ }
53+ if match, ok := score(result, query, i); ok {
54+ out = append(out, match)
31 }55 }
32 }56 }
33 sort.SliceStable(out, func(i, j int) bool {57 sort.SliceStable(out, func(i, j int) bool {
@@ -41,41 +65,81 @@ func Fuzzy(commands []command.Command, query string) []Match {
41 if a.first != b.first {65 if a.first != b.first {
42 return a.first < b.first66 return a.first < b.first
43 }67 }
44- if a.Command.Code != b.Command.Code {68+ if a.Result.Kind() != b.Result.Kind() {
45- return a.Command.Code < b.Command.Code69+ return a.Result.Kind() < b.Result.Kind()
70+ }
71+ if aText, bText := normalizedSearchText(a.Result), normalizedSearchText(b.Result); aText != bText {
72+ return aText < bText
46 }73 }
47 return a.order < b.order74 return a.order < b.order
48 })75 })
49 return out76 return out
50 }77 }
51 78
52-func score(cmd command.Command, query string, order int) (Match, bool) {79+func score(result Result, query string, order int) (Match, bool) {
53- code := []rune(cmd.Code)80+ text := normalizedSearchText(result)
54- q := []rune(query)81+ needle := strings.ToLower(query)
55- codeLower := strings.ToLower(cmd.Code)82+ textRunes, queryRunes := []rune(text), []rune(needle)
56- qLower := strings.ToLower(query)83+ match := newMatch(result, order)
57- if codeLower == qLower {84+
58- return Match{Command: cmd, Positions: rangePositions(len(code)), rank: 0, span: len(code), first: 0, order: order}, true85+ // An exact command shortcode always wins over every other match. The
86+ // remaining results then share exact/prefix/subsequence scoring.
87+ if result.Kind() == ResultKindCommand && text == needle {
88+ match.Positions = rangePositions(len(textRunes))
89+ match.rank, match.span, match.first = 0, len(textRunes), 0
90+ return match, true
59 }91 }
60- if strings.HasPrefix(codeLower, qLower) {92+ if text == needle {
61- return Match{Command: cmd, Positions: rangePositions(len(q)), rank: 1, span: len(q), first: 0, order: order}, true93+ match.Positions = rangePositions(len(textRunes))
94+ match.rank, match.span, match.first = 1, len(textRunes), 0
95+ return match, true
62 }96 }
63- if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok {97+ if strings.HasPrefix(text, needle) {
64- return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true98+ match.Positions = rangePositions(len(queryRunes))
99+ match.rank, match.span, match.first = 2, len(queryRunes), 0
100+ return match, true
65 }101 }
66- text := strings.ToLower(cmd.Desc)102+ if positions, ok := subsequencePositions(textRunes, queryRunes); ok {
67- if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok {103+ match.Positions = positions
68- return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true104+ match.rank, match.span, match.first = 3, positions[len(positions)-1]-positions[0]+1, positions[0]
105+ return match, true
106+ }
107+ if cmd, ok := resultCommand(result); ok {
108+ if positions, ok := subsequencePositions([]rune(strings.ToLower(cmd.Desc)), queryRunes); ok {
109+ match.rank, match.span, match.first = 4, positions[len(positions)-1]-positions[0]+1, positions[0]
110+ return match, true
111+ }
69 }112 }
70 return Match{}, false113 return Match{}, false
71 }114 }
72 115
116+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+ }
135+}
136+
73 func rangePositions(n int) []int {137 func rangePositions(n int) []int {
74- p := make([]int, n)138+ positions := make([]int, n)
75- for i := range p {139+ for i := range positions {
76- p[i] = i140+ positions[i] = i
77 }141 }
78- return p142+ return positions
79 }143 }
80 144
81 func subsequencePositions(haystack, needle []rune) ([]int, bool) {145 func subsequencePositions(haystack, needle []rune) ([]int, bool) {
M internal/usecase/palette/fuzzy_test.go +57 -0
@@ -2,7 +2,9 @@ package palette
22
33 import (
44 "testing"
5+ "time"
56
7+ "github.com/bnema/vev/internal/domain"
68 "github.com/bnema/vev/internal/usecase/command"
79 "github.com/stretchr/testify/require"
810 )
@@ -71,3 +73,58 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
7173
7274 require.Equal(t, []string{"B", "A"}, codes(matches))
7375 }
76+
77+func TestFuzzyMixedResultsPrioritizeCommandShortcodeAndRankSessions(t *testing.T) {
78+ created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
79+ results := []Result{
80+ NewStoppedSessionResult("work", created),
81+ NewCommandResult(cmd("WORK", "", "Create a workspace")),
82+ NewActiveSessionResult("work", created, domain.SessionID("work-id")),
83+ NewCommandResult(cmd("WQORRK", "", "")),
84+ NewCommandResult(cmd("ZZZ", "", "work tools")),
85+ }
86+
87+ matches := Fuzzy(results, "work")
88+ require.Equal(t, []string{"WORK", "work", "work", "WQORRK", "ZZZ"}, matchSearchText(matches))
89+ require.Equal(t, []ResultKind{
90+ ResultKindCommand,
91+ ResultKindActiveSession,
92+ ResultKindStoppedSession,
93+ ResultKindCommand,
94+ ResultKindCommand,
95+ }, matchKinds(matches))
96+ require.Equal(t, []int{0, 1, 2, 3}, matches[0].Positions)
97+ require.Equal(t, []int{0, 1, 2, 3}, matches[1].Positions)
98+ require.Empty(t, matches[4].Positions)
99+}
100+
101+func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) {
102+ created := time.Time{}
103+ results := []Result{
104+ NewStoppedSessionResult("aBravo", created),
105+ NewStoppedSessionResult("aAlpha", created),
106+ NewActiveSessionResult("aZulu", created, "z"),
107+ NewActiveSessionResult("aEcho", created, "e"),
108+ NewActiveSessionResult("aEcho", created, "e2"),
109+ NewCommandResult(cmd("AX", "", "")),
110+ }
111+
112+ matches := Fuzzy(results, "a")
113+ require.Equal(t, []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"}, matchSearchText(matches))
114+}
115+
116+func matchSearchText(matches []Match) []string {
117+ out := make([]string, len(matches))
118+ for i, match := range matches {
119+ out[i] = match.Result.SearchText()
120+ }
121+ return out
122+}
123+
124+func matchKinds(matches []Match) []ResultKind {
125+ out := make([]ResultKind, len(matches))
126+ for i, match := range matches {
127+ out[i] = match.Result.Kind()
128+ }
129+ return out
130+}
@@ -2,7 +2,9 @@ package palette
2 2
3 import (3 import (
4 "testing"4 "testing"
5+ "time"
5 6
7+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
7 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
8 )10 )
@@ -71,3 +73,58 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
71 73
72 require.Equal(t, []string{"B", "A"}, codes(matches))74 require.Equal(t, []string{"B", "A"}, codes(matches))
73 }75 }
76+
77+func TestFuzzyMixedResultsPrioritizeCommandShortcodeAndRankSessions(t *testing.T) {
78+ created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
79+ results := []Result{
80+ NewStoppedSessionResult("work", created),
81+ NewCommandResult(cmd("WORK", "", "Create a workspace")),
82+ NewActiveSessionResult("work", created, domain.SessionID("work-id")),
83+ NewCommandResult(cmd("WQORRK", "", "")),
84+ NewCommandResult(cmd("ZZZ", "", "work tools")),
85+ }
86+
87+ matches := Fuzzy(results, "work")
88+ require.Equal(t, []string{"WORK", "work", "work", "WQORRK", "ZZZ"}, matchSearchText(matches))
89+ require.Equal(t, []ResultKind{
90+ ResultKindCommand,
91+ ResultKindActiveSession,
92+ ResultKindStoppedSession,
93+ ResultKindCommand,
94+ ResultKindCommand,
95+ }, matchKinds(matches))
96+ require.Equal(t, []int{0, 1, 2, 3}, matches[0].Positions)
97+ require.Equal(t, []int{0, 1, 2, 3}, matches[1].Positions)
98+ require.Empty(t, matches[4].Positions)
99+}
100+
101+func TestFuzzyMixedResultsUsesKindThenNormalizedTextThenStableOrder(t *testing.T) {
102+ created := time.Time{}
103+ results := []Result{
104+ NewStoppedSessionResult("aBravo", created),
105+ NewStoppedSessionResult("aAlpha", created),
106+ NewActiveSessionResult("aZulu", created, "z"),
107+ NewActiveSessionResult("aEcho", created, "e"),
108+ NewActiveSessionResult("aEcho", created, "e2"),
109+ NewCommandResult(cmd("AX", "", "")),
110+ }
111+
112+ matches := Fuzzy(results, "a")
113+ require.Equal(t, []string{"AX", "aEcho", "aEcho", "aZulu", "aAlpha", "aBravo"}, matchSearchText(matches))
114+}
115+
116+func matchSearchText(matches []Match) []string {
117+ out := make([]string, len(matches))
118+ for i, match := range matches {
119+ out[i] = match.Result.SearchText()
120+ }
121+ return out
122+}
123+
124+func matchKinds(matches []Match) []ResultKind {
125+ out := make([]ResultKind, len(matches))
126+ for i, match := range matches {
127+ out[i] = match.Result.Kind()
128+ }
129+ return out
130+}
M internal/usecase/palette/model.go +98 -57
@@ -23,6 +23,7 @@ type RenderOptions struct {
2323 }
2424
2525 type Model struct {
26+ results []Result
2627 commands []command.Command
2728 input ui.TextInput
2829 matches []Match
@@ -30,13 +31,31 @@ type Model struct {
3031 scroll int
3132 }
3233
33-func New(commands []command.Command) *Model {
34- m := &Model{commands: append([]command.Command(nil), commands...)}
34+// New accepts typed results. []command.Command remains supported while callers
35+// migrate, and is immediately converted to immutable CommandResults.
36+func New(items any) *Model {
37+ m := &Model{results: paletteResults(items)}
38+ for _, result := range m.results {
39+ if cmd, ok := resultCommand(result); ok {
40+ m.commands = append(m.commands, cmd)
41+ }
42+ }
3543 m.refresh()
3644 return m
3745 }
3846
39-func NewRegistry() *Model { return New(command.Registry()) }
47+func paletteResults(items any) []Result {
48+ switch values := items.(type) {
49+ case []Result:
50+ return append([]Result(nil), values...)
51+ case []command.Command:
52+ return commandResults(values)
53+ default:
54+ return nil
55+ }
56+}
57+
58+func NewRegistry() *Model { return New(commandResults(command.Registry())) }
4059
4160 func DefaultRenderStyles() RenderStyles {
4261 selection := renderer.DefaultStyle()
@@ -49,16 +68,14 @@ func DefaultRenderStyles() RenderStyles {
4968 func (m *Model) Insert(r rune) {
5069 if m != nil {
5170 m.input.Insert(r)
52- m.selected = 0
53- m.scroll = 0
71+ m.selected, m.scroll = 0, 0
5472 m.refresh()
5573 }
5674 }
5775 func (m *Model) Backspace() {
5876 if m != nil && m.input.Value() != "" {
5977 m.input.Backspace()
60- m.selected = 0
61- m.scroll = 0
78+ m.selected, m.scroll = 0, 0
6279 m.refresh()
6380 }
6481 }
@@ -81,26 +98,32 @@ func (m *Model) Down() {
8198 m.clamp()
8299 }
83100 }
84-func (m *Model) Selected() (command.Command, bool) {
101+
102+// Selected returns the typed immutable target, not an executable command.
103+func (m *Model) Selected() (Result, bool) {
85104 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
86- return command.Command{}, false
105+ return nil, false
87106 }
88- return m.matches[m.selected].Command, true
107+ return m.matches[m.selected].Result, true
89108 }
90109
91-// CompleteSelected replaces the query with the selected command's effective
92-// code. Required-argument commands retain the typed argument bytes.
110+// CompleteSelected only completes static command results. Session targets can
111+// never be parsed, completed, or otherwise treated as commands.
93112 func (m *Model) CompleteSelected() bool {
94113 selected, ok := m.Selected()
95114 if !ok {
96115 return false
97116 }
117+ cmd, ok := resultCommand(selected)
118+ if !ok {
119+ return false
120+ }
98121
99122 query := m.Query()
100- completed := selected.Code
101- if selected.Arguments == command.ArgumentsRequired {
123+ completed := cmd.Code
124+ if cmd.Arguments == command.ArgumentsRequired {
102125 token, args, hasSeparator := completionParts(query)
103- if token == selected.Code && hasSeparator && args != "" {
126+ if token == cmd.Code && hasSeparator && args != "" {
104127 return false
105128 }
106129 completed += " "
@@ -113,8 +136,7 @@ func (m *Model) CompleteSelected() bool {
113136 }
114137
115138 m.input.SetValue(completed)
116- m.selected = 0
117- m.scroll = 0
139+ m.selected, m.scroll = 0, 0
118140 m.refresh()
119141 return true
120142 }
@@ -136,7 +158,7 @@ func completionParts(query string) (token, args string, hasSeparator bool) {
136158 return query, "", false
137159 }
138160
139-// ArgumentCommand returns the exact argument-taking command being entered.
161+// ArgumentCommand returns the exact argument-taking static command being entered.
140162 func (m *Model) ArgumentCommand() (command.Command, bool) {
141163 if m == nil {
142164 return command.Command{}, false
@@ -158,8 +180,8 @@ func (m *Model) Matches() []Match {
158180
159181 func (m *Model) refresh() {
160182 query := m.input.Value()
161- m.matches = Fuzzy(m.commands, query)
162- // Once an argument-taking token is exact, retain its row while its
183+ m.matches = fuzzyResults(m.results, query)
184+ // Once an argument-taking static token is exact, retain its row while its
163185 // arguments make ordinary fuzzy matching inapplicable.
164186 if cmd, ok := ArgumentCommand(m.commands, query); ok {
165187 m.prependMatch(cmd)
@@ -169,7 +191,7 @@ func (m *Model) refresh() {
169191 // the partial first token so Tab can complete them.
170192 token, _, hasSeparator := completionParts(query)
171193 if hasSeparator {
172- for _, match := range Fuzzy(m.commands, token) {
194+ for _, match := range fuzzyResults(commandResults(m.commands), token) {
173195 if match.Command.Arguments == command.ArgumentsRequired {
174196 m.matches = append(m.matches, match)
175197 }
@@ -190,12 +212,11 @@ func (m *Model) prependMatch(cmd command.Command) {
190212 }
191213 return
192214 }
193- m.matches = append([]Match{{Command: cmd}}, m.matches...)
215+ m.matches = append([]Match{{Result: NewCommandResult(cmd), Command: cmd}}, m.matches...)
194216 }
195217 func (m *Model) clamp() {
196218 if len(m.matches) == 0 {
197- m.selected = -1
198- m.scroll = 0
219+ m.selected, m.scroll = -1, 0
199220 return
200221 }
201222 if m.selected < 0 {
@@ -218,21 +239,15 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
218239 if frame.Width == 0 || frame.Height == 0 {
219240 return frame
220241 }
221- base := renderer.DefaultStyle()
222- selection := styles.Selection
223- desc := styles.Description
242+ base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description
224243 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
225244 if m == nil {
226245 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
227246 return frame
228247 }
229248 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
230- start := 1
231- visible := frame.Height - start
232- if visible <= 0 {
233- return frame
234- }
235- if len(m.matches) == 0 {
249+ start, visible := 1, frame.Height-1
250+ if visible <= 0 || len(m.matches) == 0 {
236251 return frame
237252 }
238253 if m.selected < m.scroll {
@@ -243,8 +258,8 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
243258 }
244259 codeWidth := 0
245260 for _, match := range m.matches {
246- if len([]rune(match.Command.Code)) > codeWidth {
247- codeWidth = len([]rune(match.Command.Code))
261+ if cmd, ok := resultCommand(match.Result); ok && len([]rune(cmd.Code)) > codeWidth {
262+ codeWidth = len([]rune(cmd.Code))
248263 }
249264 }
250265 activeCmd, activeOK := m.ArgumentCommand()
@@ -259,34 +274,60 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
259274 style = selection
260275 }
261276 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
262- x := 0
263- highlight := map[int]bool{}
264- for _, p := range match.Positions {
265- highlight[p] = true
277+ if cmd, ok := resultCommand(match.Result); ok {
278+ m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance)
279+ continue
266280 }
267- for i, r := range []rune(match.Command.Code) {
268- cellStyle := style
281+ m.renderSession(frame, y+start, style, selection, match)
282+ }
283+ return frame
284+}
285+
286+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 string) {
287+ x, highlight := 0, map[int]bool{}
288+ for _, position := range positions {
289+ highlight[position] = true
290+ }
291+ for i, r := range []rune(cmd.Code) {
292+ cellStyle := style
293+ cellStyle.Bold = true
294+ if highlight[i] {
295+ cellStyle = selection
269296 cellStyle.Bold = true
270- if highlight[i] {
271- cellStyle = selection
272- cellStyle.Bold = true
273- }
274- if x < frame.Width {
275- frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle})
276- }
277- x++
278297 }
279- for x < codeWidth+1 && x < frame.Width {
280- frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style})
281- x++
298+ if x < frame.Width {
299+ frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
282300 }
283- description := match.Command.Desc
284- if opts.Guidance != "" && activeOK && match.Command.ContextHint != command.ContextHintNone && activeCmd.Code == match.Command.Code {
285- description = opts.Guidance
301+ x++
302+ }
303+ for x < codeWidth+1 && x < frame.Width {
304+ frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
305+ x++
306+ }
307+ description := cmd.Desc
308+ if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
309+ description = guidance
310+ }
311+ ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc))
312+}
313+
314+func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match) {
315+ text := []rune(match.Result.DisplayText())
316+ highlight := map[int]bool{}
317+ prefix := len([]rune("Switch to session "))
318+ for _, position := range match.Positions {
319+ highlight[prefix+position] = true
320+ }
321+ for x, r := range text {
322+ cellStyle := style
323+ if highlight[x] {
324+ cellStyle = selection
325+ cellStyle.Bold = true
326+ }
327+ if x < frame.Width {
328+ frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
286329 }
287- ui.DrawText(frame, x, y+start, frame.Width, description, mergePaletteDescStyle(style, desc))
288330 }
289- return frame
290331 }
291332
292333 func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {
@@ -23,6 +23,7 @@ type RenderOptions struct {
23 }23 }
24 24
25 type Model struct {25 type Model struct {
26+ results []Result
26 commands []command.Command27 commands []command.Command
27 input ui.TextInput28 input ui.TextInput
28 matches []Match29 matches []Match
@@ -30,13 +31,31 @@ type Model struct {
30 scroll int31 scroll int
31 }32 }
32 33
33-func New(commands []command.Command) *Model {34+// New accepts typed results. []command.Command remains supported while callers
34- m := &Model{commands: append([]command.Command(nil), commands...)}35+// migrate, and is immediately converted to immutable CommandResults.
36+func New(items any) *Model {
37+ m := &Model{results: paletteResults(items)}
38+ for _, result := range m.results {
39+ if cmd, ok := resultCommand(result); ok {
40+ m.commands = append(m.commands, cmd)
41+ }
42+ }
35 m.refresh()43 m.refresh()
36 return m44 return m
37 }45 }
38 46
39-func NewRegistry() *Model { return New(command.Registry()) }47+func paletteResults(items any) []Result {
48+ switch values := items.(type) {
49+ case []Result:
50+ return append([]Result(nil), values...)
51+ case []command.Command:
52+ return commandResults(values)
53+ default:
54+ return nil
55+ }
56+}
57+
58+func NewRegistry() *Model { return New(commandResults(command.Registry())) }
40 59
41 func DefaultRenderStyles() RenderStyles {60 func DefaultRenderStyles() RenderStyles {
42 selection := renderer.DefaultStyle()61 selection := renderer.DefaultStyle()
@@ -49,16 +68,14 @@ func DefaultRenderStyles() RenderStyles {
49 func (m *Model) Insert(r rune) {68 func (m *Model) Insert(r rune) {
50 if m != nil {69 if m != nil {
51 m.input.Insert(r)70 m.input.Insert(r)
52- m.selected = 071+ m.selected, m.scroll = 0, 0
53- m.scroll = 0
54 m.refresh()72 m.refresh()
55 }73 }
56 }74 }
57 func (m *Model) Backspace() {75 func (m *Model) Backspace() {
58 if m != nil && m.input.Value() != "" {76 if m != nil && m.input.Value() != "" {
59 m.input.Backspace()77 m.input.Backspace()
60- m.selected = 078+ m.selected, m.scroll = 0, 0
61- m.scroll = 0
62 m.refresh()79 m.refresh()
63 }80 }
64 }81 }
@@ -81,26 +98,32 @@ func (m *Model) Down() {
81 m.clamp()98 m.clamp()
82 }99 }
83 }100 }
84-func (m *Model) Selected() (command.Command, bool) {101+
102+// Selected returns the typed immutable target, not an executable command.
103+func (m *Model) Selected() (Result, bool) {
85 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {104 if m == nil || m.selected < 0 || m.selected >= len(m.matches) {
86- return command.Command{}, false105+ return nil, false
87 }106 }
88- return m.matches[m.selected].Command, true107+ return m.matches[m.selected].Result, true
89 }108 }
90 109
91-// CompleteSelected replaces the query with the selected command's effective110+// CompleteSelected only completes static command results. Session targets can
92-// code. Required-argument commands retain the typed argument bytes.111+// never be parsed, completed, or otherwise treated as commands.
93 func (m *Model) CompleteSelected() bool {112 func (m *Model) CompleteSelected() bool {
94 selected, ok := m.Selected()113 selected, ok := m.Selected()
95 if !ok {114 if !ok {
96 return false115 return false
97 }116 }
117+ cmd, ok := resultCommand(selected)
118+ if !ok {
119+ return false
120+ }
98 121
99 query := m.Query()122 query := m.Query()
100- completed := selected.Code123+ completed := cmd.Code
101- if selected.Arguments == command.ArgumentsRequired {124+ if cmd.Arguments == command.ArgumentsRequired {
102 token, args, hasSeparator := completionParts(query)125 token, args, hasSeparator := completionParts(query)
103- if token == selected.Code && hasSeparator && args != "" {126+ if token == cmd.Code && hasSeparator && args != "" {
104 return false127 return false
105 }128 }
106 completed += " "129 completed += " "
@@ -113,8 +136,7 @@ func (m *Model) CompleteSelected() bool {
113 }136 }
114 137
115 m.input.SetValue(completed)138 m.input.SetValue(completed)
116- m.selected = 0139+ m.selected, m.scroll = 0, 0
117- m.scroll = 0
118 m.refresh()140 m.refresh()
119 return true141 return true
120 }142 }
@@ -136,7 +158,7 @@ func completionParts(query string) (token, args string, hasSeparator bool) {
136 return query, "", false158 return query, "", false
137 }159 }
138 160
139-// ArgumentCommand returns the exact argument-taking command being entered.161+// ArgumentCommand returns the exact argument-taking static command being entered.
140 func (m *Model) ArgumentCommand() (command.Command, bool) {162 func (m *Model) ArgumentCommand() (command.Command, bool) {
141 if m == nil {163 if m == nil {
142 return command.Command{}, false164 return command.Command{}, false
@@ -158,8 +180,8 @@ func (m *Model) Matches() []Match {
158 180
159 func (m *Model) refresh() {181 func (m *Model) refresh() {
160 query := m.input.Value()182 query := m.input.Value()
161- m.matches = Fuzzy(m.commands, query)183+ m.matches = fuzzyResults(m.results, query)
162- // Once an argument-taking token is exact, retain its row while its184+ // Once an argument-taking static token is exact, retain its row while its
163 // arguments make ordinary fuzzy matching inapplicable.185 // arguments make ordinary fuzzy matching inapplicable.
164 if cmd, ok := ArgumentCommand(m.commands, query); ok {186 if cmd, ok := ArgumentCommand(m.commands, query); ok {
165 m.prependMatch(cmd)187 m.prependMatch(cmd)
@@ -169,7 +191,7 @@ func (m *Model) refresh() {
169 // the partial first token so Tab can complete them.191 // the partial first token so Tab can complete them.
170 token, _, hasSeparator := completionParts(query)192 token, _, hasSeparator := completionParts(query)
171 if hasSeparator {193 if hasSeparator {
172- for _, match := range Fuzzy(m.commands, token) {194+ for _, match := range fuzzyResults(commandResults(m.commands), token) {
173 if match.Command.Arguments == command.ArgumentsRequired {195 if match.Command.Arguments == command.ArgumentsRequired {
174 m.matches = append(m.matches, match)196 m.matches = append(m.matches, match)
175 }197 }
@@ -190,12 +212,11 @@ func (m *Model) prependMatch(cmd command.Command) {
190 }212 }
191 return213 return
192 }214 }
193- m.matches = append([]Match{{Command: cmd}}, m.matches...)215+ m.matches = append([]Match{{Result: NewCommandResult(cmd), Command: cmd}}, m.matches...)
194 }216 }
195 func (m *Model) clamp() {217 func (m *Model) clamp() {
196 if len(m.matches) == 0 {218 if len(m.matches) == 0 {
197- m.selected = -1219+ m.selected, m.scroll = -1, 0
198- m.scroll = 0
199 return220 return
200 }221 }
201 if m.selected < 0 {222 if m.selected < 0 {
@@ -218,21 +239,15 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
218 if frame.Width == 0 || frame.Height == 0 {239 if frame.Width == 0 || frame.Height == 0 {
219 return frame240 return frame
220 }241 }
221- base := renderer.DefaultStyle()242+ base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description
222- selection := styles.Selection
223- desc := styles.Description
224 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})243 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
225 if m == nil {244 if m == nil {
226 ui.DrawInputLine(frame, 0, "> ", "", base, selection)245 ui.DrawInputLine(frame, 0, "> ", "", base, selection)
227 return frame246 return frame
228 }247 }
229 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)248 ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
230- start := 1249+ start, visible := 1, frame.Height-1
231- visible := frame.Height - start250+ if visible <= 0 || len(m.matches) == 0 {
232- if visible <= 0 {
233- return frame
234- }
235- if len(m.matches) == 0 {
236 return frame251 return frame
237 }252 }
238 if m.selected < m.scroll {253 if m.selected < m.scroll {
@@ -243,8 +258,8 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
243 }258 }
244 codeWidth := 0259 codeWidth := 0
245 for _, match := range m.matches {260 for _, match := range m.matches {
246- if len([]rune(match.Command.Code)) > codeWidth {261+ if cmd, ok := resultCommand(match.Result); ok && len([]rune(cmd.Code)) > codeWidth {
247- codeWidth = len([]rune(match.Command.Code))262+ codeWidth = len([]rune(cmd.Code))
248 }263 }
249 }264 }
250 activeCmd, activeOK := m.ArgumentCommand()265 activeCmd, activeOK := m.ArgumentCommand()
@@ -259,34 +274,60 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame {
259 style = selection274 style = selection
260 }275 }
261 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})276 ui.FillRect(frame, domain.Rect{Y: y + start, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
262- x := 0277+ if cmd, ok := resultCommand(match.Result); ok {
263- highlight := map[int]bool{}278+ m.renderCommand(frame, y+start, style, selection, desc, codeWidth, cmd, match.Positions, activeCmd, activeOK, opts.Guidance)
264- for _, p := range match.Positions {279+ continue
265- highlight[p] = true
266 }280 }
267- for i, r := range []rune(match.Command.Code) {281+ m.renderSession(frame, y+start, style, selection, match)
268- cellStyle := style282+ }
283+ return frame
284+}
285+
286+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 string) {
287+ x, highlight := 0, map[int]bool{}
288+ for _, position := range positions {
289+ highlight[position] = true
290+ }
291+ for i, r := range []rune(cmd.Code) {
292+ cellStyle := style
293+ cellStyle.Bold = true
294+ if highlight[i] {
295+ cellStyle = selection
269 cellStyle.Bold = true296 cellStyle.Bold = true
270- if highlight[i] {
271- cellStyle = selection
272- cellStyle.Bold = true
273- }
274- if x < frame.Width {
275- frame.Set(x, y+start, renderer.Cell{Rune: r, Style: cellStyle})
276- }
277- x++
278 }297 }
279- for x < codeWidth+1 && x < frame.Width {298+ if x < frame.Width {
280- frame.Set(x, y+start, renderer.Cell{Rune: ' ', Style: style})299+ frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
281- x++
282 }300 }
283- description := match.Command.Desc301+ x++
284- if opts.Guidance != "" && activeOK && match.Command.ContextHint != command.ContextHintNone && activeCmd.Code == match.Command.Code {302+ }
285- description = opts.Guidance303+ for x < codeWidth+1 && x < frame.Width {
304+ frame.Set(x, y, renderer.Cell{Rune: ' ', Style: style})
305+ x++
306+ }
307+ description := cmd.Desc
308+ if guidance != "" && activeOK && cmd.ContextHint != command.ContextHintNone && activeCmd.Code == cmd.Code {
309+ description = guidance
310+ }
311+ ui.DrawText(frame, x, y, frame.Width, description, mergePaletteDescStyle(style, desc))
312+}
313+
314+func (m *Model) renderSession(frame renderer.Frame, y int, style, selection renderer.Style, match Match) {
315+ text := []rune(match.Result.DisplayText())
316+ highlight := map[int]bool{}
317+ prefix := len([]rune("Switch to session "))
318+ for _, position := range match.Positions {
319+ highlight[prefix+position] = true
320+ }
321+ for x, r := range text {
322+ cellStyle := style
323+ if highlight[x] {
324+ cellStyle = selection
325+ cellStyle.Bold = true
326+ }
327+ if x < frame.Width {
328+ frame.Set(x, y, renderer.Cell{Rune: r, Style: cellStyle})
286 }329 }
287- ui.DrawText(frame, x, y+start, frame.Width, description, mergePaletteDescStyle(style, desc))
288 }330 }
289- return frame
290 }331 }
291 332
292 func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {333 func mergePaletteDescStyle(line, desc renderer.Style) renderer.Style {
M internal/usecase/palette/model_test.go +53 -6
@@ -3,6 +3,7 @@ package palette
33 import (
44 "fmt"
55 "testing"
6+ "time"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/usecase/command"
@@ -20,24 +21,24 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
2021 require.Equal(t, "", m.Query())
2122 selected, ok := m.Selected()
2223 require.True(t, ok)
23- require.Equal(t, "ABC", selected.Code)
24+ require.Equal(t, "ABC", selectedCommandCode(t, selected))
2425
2526 m.Down()
2627 m.Down()
2728 selected, ok = m.Selected()
2829 require.True(t, ok)
29- require.Equal(t, "AXY", selected.Code)
30+ require.Equal(t, "AXY", selectedCommandCode(t, selected))
3031
3132 m.Insert('d')
3233 require.Equal(t, "d", m.Query())
3334 selected, ok = m.Selected()
3435 require.True(t, ok)
35- require.Equal(t, "DEF", selected.Code, "selection clamps to only match after query changes")
36+ require.Equal(t, "DEF", selectedCommandCode(t, selected), "selection clamps to only match after query changes")
3637
3738 m.Up()
3839 selected, ok = m.Selected()
3940 require.True(t, ok)
40- require.Equal(t, "DEF", selected.Code, "up at first match clamps")
41+ require.Equal(t, "DEF", selectedCommandCode(t, selected), "up at first match clamps")
4142
4243 m.Backspace()
4344 require.Equal(t, "", m.Query())
@@ -87,7 +88,7 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
8788 require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")
8889 selected, ok := m.Selected()
8990 require.True(t, ok)
90- require.Equal(t, "ZZZ", selected.Code)
91+ require.Equal(t, "ZZZ", selectedCommandCode(t, selected))
9192 }
9293
9394 func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
@@ -102,7 +103,11 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
102103 commands: func(zzz command.Command) []command.Command {
103104 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
104105 },
105- want: Match{Command: command.Command{
106+ want: Match{Result: NewCommandResult(command.Command{
107+ Code: "ZZZ",
108+ Desc: "unmatched",
109+ Arguments: command.ArgumentsRequired,
110+ }), Command: command.Command{
106111 Code: "ZZZ",
107112 Desc: "unmatched",
108113 Arguments: command.ArgumentsRequired,
@@ -179,6 +184,13 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
179184 }
180185 }
181186
187+func selectedCommandCode(t *testing.T, result Result) string {
188+ t.Helper()
189+ cmd, ok := resultCommand(result)
190+ require.True(t, ok, "selected result is a command")
191+ return cmd.Code
192+}
193+
182194 func frameRow(frame renderer.Frame, y int) string {
183195 row := make([]rune, frame.Width)
184196 for x := range frame.Width {
@@ -319,6 +331,41 @@ func TestModelCompleteSelected(t *testing.T) {
319331 }
320332 }
321333
334+func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
335+ created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
336+ results := []Result{
337+ NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
338+ NewActiveSessionResult("work", created, "work-id"),
339+ NewStoppedSessionResult("work", created),
340+ }
341+ m := New(results)
342+ results[1] = NewActiveSessionResult("changed", created, "changed-id")
343+
344+ for _, r := range "work" {
345+ m.Insert(r)
346+ }
347+ matches := m.Matches()
348+ require.Len(t, matches, 2)
349+ require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
350+ matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
351+ require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
352+
353+ selected, ok := m.Selected()
354+ require.True(t, ok)
355+ session, ok := selected.(SessionResult)
356+ require.True(t, ok)
357+ id, active := session.SessionID()
358+ require.True(t, active)
359+ require.Equal(t, domain.SessionID("work-id"), id)
360+ require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
361+ require.Equal(t, "work", m.Query())
362+ _, argument := m.ArgumentCommand()
363+ require.False(t, argument)
364+
365+ frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
366+ require.Equal(t, "Switch to session work ", frameRow(frame, 1))
367+}
368+
322369 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
323370 jrs := cmd("JRS", "Jump", "Jump to recent session")
324371 jrs.Arguments = command.ArgumentsRequired
@@ -3,6 +3,7 @@ package palette
3 import (3 import (
4 "fmt"4 "fmt"
5 "testing"5 "testing"
6+ "time"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/usecase/command"9 "github.com/bnema/vev/internal/usecase/command"
@@ -20,24 +21,24 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
20 require.Equal(t, "", m.Query())21 require.Equal(t, "", m.Query())
21 selected, ok := m.Selected()22 selected, ok := m.Selected()
22 require.True(t, ok)23 require.True(t, ok)
23- require.Equal(t, "ABC", selected.Code)24+ require.Equal(t, "ABC", selectedCommandCode(t, selected))
24 25
25 m.Down()26 m.Down()
26 m.Down()27 m.Down()
27 selected, ok = m.Selected()28 selected, ok = m.Selected()
28 require.True(t, ok)29 require.True(t, ok)
29- require.Equal(t, "AXY", selected.Code)30+ require.Equal(t, "AXY", selectedCommandCode(t, selected))
30 31
31 m.Insert('d')32 m.Insert('d')
32 require.Equal(t, "d", m.Query())33 require.Equal(t, "d", m.Query())
33 selected, ok = m.Selected()34 selected, ok = m.Selected()
34 require.True(t, ok)35 require.True(t, ok)
35- require.Equal(t, "DEF", selected.Code, "selection clamps to only match after query changes")36+ require.Equal(t, "DEF", selectedCommandCode(t, selected), "selection clamps to only match after query changes")
36 37
37 m.Up()38 m.Up()
38 selected, ok = m.Selected()39 selected, ok = m.Selected()
39 require.True(t, ok)40 require.True(t, ok)
40- require.Equal(t, "DEF", selected.Code, "up at first match clamps")41+ require.Equal(t, "DEF", selectedCommandCode(t, selected), "up at first match clamps")
41 42
42 m.Backspace()43 m.Backspace()
43 require.Equal(t, "", m.Query())44 require.Equal(t, "", m.Query())
@@ -87,7 +88,7 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
87 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")
88 selected, ok := m.Selected()89 selected, ok := m.Selected()
89 require.True(t, ok)90 require.True(t, ok)
90- require.Equal(t, "ZZZ", selected.Code)91+ require.Equal(t, "ZZZ", selectedCommandCode(t, selected))
91 }92 }
92 93
93 func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {94 func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
@@ -102,7 +103,11 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
102 commands: func(zzz command.Command) []command.Command {103 commands: func(zzz command.Command) []command.Command {
103 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}104 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
104 },105 },
105- want: Match{Command: command.Command{106+ want: Match{Result: NewCommandResult(command.Command{
107+ Code: "ZZZ",
108+ Desc: "unmatched",
109+ Arguments: command.ArgumentsRequired,
110+ }), Command: command.Command{
106 Code: "ZZZ",111 Code: "ZZZ",
107 Desc: "unmatched",112 Desc: "unmatched",
108 Arguments: command.ArgumentsRequired,113 Arguments: command.ArgumentsRequired,
@@ -179,6 +184,13 @@ func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
179 }184 }
180 }185 }
181 186
187+func selectedCommandCode(t *testing.T, result Result) string {
188+ t.Helper()
189+ cmd, ok := resultCommand(result)
190+ require.True(t, ok, "selected result is a command")
191+ return cmd.Code
192+}
193+
182 func frameRow(frame renderer.Frame, y int) string {194 func frameRow(frame renderer.Frame, y int) string {
183 row := make([]rune, frame.Width)195 row := make([]rune, frame.Width)
184 for x := range frame.Width {196 for x := range frame.Width {
@@ -319,6 +331,41 @@ func TestModelCompleteSelected(t *testing.T) {
319 }331 }
320 }332 }
321 333
334+func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
335+ created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
336+ results := []Result{
337+ NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
338+ NewActiveSessionResult("work", created, "work-id"),
339+ NewStoppedSessionResult("work", created),
340+ }
341+ m := New(results)
342+ results[1] = NewActiveSessionResult("changed", created, "changed-id")
343+
344+ for _, r := range "work" {
345+ m.Insert(r)
346+ }
347+ matches := m.Matches()
348+ require.Len(t, matches, 2)
349+ require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
350+ matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
351+ require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
352+
353+ selected, ok := m.Selected()
354+ require.True(t, ok)
355+ session, ok := selected.(SessionResult)
356+ require.True(t, ok)
357+ id, active := session.SessionID()
358+ require.True(t, active)
359+ require.Equal(t, domain.SessionID("work-id"), id)
360+ require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
361+ require.Equal(t, "work", m.Query())
362+ _, argument := m.ArgumentCommand()
363+ require.False(t, argument)
364+
365+ frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
366+ require.Equal(t, "Switch to session work ", frameRow(frame, 1))
367+}
368+
322 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {369 func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
323 jrs := cmd("JRS", "Jump", "Jump to recent session")370 jrs := cmd("JRS", "Jump", "Jump to recent session")
324 jrs.Arguments = command.ArgumentsRequired371 jrs.Arguments = command.ArgumentsRequired
A internal/usecase/palette/result.go +75 -0
@@ -0,0 +1,75 @@
1+package palette
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/command"
8+)
9+
10+// ResultKind identifies the immutable target represented by a palette row.
11+type ResultKind uint8
12+
13+const (
14+ ResultKindCommand ResultKind = iota
15+ ResultKindActiveSession
16+ ResultKindStoppedSession
17+)
18+
19+// Result is a searchable palette target. Only CommandResult is executable as
20+// a command; session results deliberately carry no command semantics.
21+type Result interface {
22+ Kind() ResultKind
23+ DisplayText() string
24+ SearchText() string
25+}
26+
27+// CommandResult is a static command palette target.
28+type CommandResult struct {
29+ command command.Command
30+}
31+
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 }
37+
38+// SessionResult is an immutable named-session target. An active session has a
39+// SessionID; a stopped session intentionally does not.
40+type SessionResult struct {
41+ name string
42+ createdAt time.Time
43+ sessionID domain.SessionID
44+ active bool
45+}
46+
47+func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) SessionResult {
48+ return SessionResult{name: name, createdAt: createdAt, sessionID: sessionID, active: true}
49+}
50+
51+func NewStoppedSessionResult(name string, createdAt time.Time) SessionResult {
52+ return SessionResult{name: name, createdAt: createdAt}
53+}
54+
55+func (r SessionResult) Kind() ResultKind {
56+ if r.active {
57+ return ResultKindActiveSession
58+ }
59+ return ResultKindStoppedSession
60+}
61+func (r SessionResult) Name() string { return r.name }
62+func (r SessionResult) CreatedAt() time.Time { return r.createdAt }
63+func (r SessionResult) SessionID() (domain.SessionID, bool) {
64+ return r.sessionID, r.active
65+}
66+func (r SessionResult) DisplayText() string { return "Switch to session " + r.name }
67+func (r SessionResult) SearchText() string { return r.name }
68+
69+func commandResults(commands []command.Command) []Result {
70+ results := make([]Result, len(commands))
71+ for i, cmd := range commands {
72+ results[i] = NewCommandResult(cmd)
73+ }
74+ return results
75+}
@@ -0,0 +1,75 @@
1+package palette
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/command"
8+)
9+
10+// ResultKind identifies the immutable target represented by a palette row.
11+type ResultKind uint8
12+
13+const (
14+ ResultKindCommand ResultKind = iota
15+ ResultKindActiveSession
16+ ResultKindStoppedSession
17+)
18+
19+// Result is a searchable palette target. Only CommandResult is executable as
20+// a command; session results deliberately carry no command semantics.
21+type Result interface {
22+ Kind() ResultKind
23+ DisplayText() string
24+ SearchText() string
25+}
26+
27+// CommandResult is a static command palette target.
28+type CommandResult struct {
29+ command command.Command
30+}
31+
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 }
37+
38+// SessionResult is an immutable named-session target. An active session has a
39+// SessionID; a stopped session intentionally does not.
40+type SessionResult struct {
41+ name string
42+ createdAt time.Time
43+ sessionID domain.SessionID
44+ active bool
45+}
46+
47+func NewActiveSessionResult(name string, createdAt time.Time, sessionID domain.SessionID) SessionResult {
48+ return SessionResult{name: name, createdAt: createdAt, sessionID: sessionID, active: true}
49+}
50+
51+func NewStoppedSessionResult(name string, createdAt time.Time) SessionResult {
52+ return SessionResult{name: name, createdAt: createdAt}
53+}
54+
55+func (r SessionResult) Kind() ResultKind {
56+ if r.active {
57+ return ResultKindActiveSession
58+ }
59+ return ResultKindStoppedSession
60+}
61+func (r SessionResult) Name() string { return r.name }
62+func (r SessionResult) CreatedAt() time.Time { return r.createdAt }
63+func (r SessionResult) SessionID() (domain.SessionID, bool) {
64+ return r.sessionID, r.active
65+}
66+func (r SessionResult) DisplayText() string { return "Switch to session " + r.name }
67+func (r SessionResult) SearchText() string { return r.name }
68+
69+func commandResults(commands []command.Command) []Result {
70+ results := make([]Result, len(commands))
71+ for i, cmd := range commands {
72+ results[i] = NewCommandResult(cmd)
73+ }
74+ return results
75+}
A internal/usecase/palette/result_test.go +36 -0
@@ -0,0 +1,36 @@
1+package palette
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/command"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
13+ created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
14+ commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"})
15+ active := NewActiveSessionResult("work", created, domain.SessionID("session-work"))
16+ stopped := NewStoppedSessionResult("archive", created)
17+
18+ require.Equal(t, ResultKindCommand, commandResult.Kind())
19+ require.Equal(t, "NT", commandResult.DisplayText())
20+ require.Equal(t, "NT", commandResult.SearchText())
21+
22+ require.Equal(t, ResultKindActiveSession, active.Kind())
23+ require.Equal(t, "work", active.Name())
24+ require.Equal(t, created, active.CreatedAt())
25+ require.Equal(t, "Switch to session work", active.DisplayText())
26+ require.Equal(t, "work", active.SearchText())
27+ id, ok := active.SessionID()
28+ require.True(t, ok)
29+ require.Equal(t, domain.SessionID("session-work"), id)
30+
31+ require.Equal(t, ResultKindStoppedSession, stopped.Kind())
32+ require.Equal(t, "archive", stopped.Name())
33+ require.Equal(t, created, stopped.CreatedAt())
34+ _, ok = stopped.SessionID()
35+ require.False(t, ok)
36+}
@@ -0,0 +1,36 @@
1+package palette
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/usecase/command"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestResultKindsAndSessionLifecycleTargets(t *testing.T) {
13+ created := time.Date(2026, time.March, 1, 2, 3, 4, 0, time.UTC)
14+ commandResult := NewCommandResult(command.Command{Code: "NT", Desc: "Create tab"})
15+ active := NewActiveSessionResult("work", created, domain.SessionID("session-work"))
16+ stopped := NewStoppedSessionResult("archive", created)
17+
18+ require.Equal(t, ResultKindCommand, commandResult.Kind())
19+ require.Equal(t, "NT", commandResult.DisplayText())
20+ require.Equal(t, "NT", commandResult.SearchText())
21+
22+ require.Equal(t, ResultKindActiveSession, active.Kind())
23+ require.Equal(t, "work", active.Name())
24+ require.Equal(t, created, active.CreatedAt())
25+ require.Equal(t, "Switch to session work", active.DisplayText())
26+ require.Equal(t, "work", active.SearchText())
27+ id, ok := active.SessionID()
28+ require.True(t, ok)
29+ require.Equal(t, domain.SessionID("session-work"), id)
30+
31+ require.Equal(t, ResultKindStoppedSession, stopped.Kind())
32+ require.Equal(t, "archive", stopped.Name())
33+ require.Equal(t, created, stopped.CreatedAt())
34+ _, ok = stopped.SessionID()
35+ require.False(t, ok)
36+}

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