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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +410 -90
M internal/usecase/palette/fuzzy.go +91 -27
| @@ -7,7 +7,10 @@ import ( | |||
| 7 | 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 | 12 | type Match struct { | |
| 13 | + | Result Result | |
| 11 | 14 | Command command.Command | |
| 12 | 15 | Positions []int | |
| 13 | 16 | rank int | |
| @@ -16,18 +19,39 @@ type Match struct { | |||
| 16 | 19 | 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 | 38 | 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 | + | } | |
| 24 | 44 | } | |
| 25 | 45 | return out | |
| 26 | 46 | } | |
| 47 | + | ||
| 27 | 48 | 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) | |
| 31 | 55 | } | |
| 32 | 56 | } | |
| 33 | 57 | sort.SliceStable(out, func(i, j int) bool { | |
| @@ -41,41 +65,81 @@ func Fuzzy(commands []command.Command, query string) []Match { | |||
| 41 | 65 | if a.first != b.first { | |
| 42 | 66 | return a.first < b.first | |
| 43 | 67 | } | |
| 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 | |
| 46 | 73 | } | |
| 47 | 74 | return a.order < b.order | |
| 48 | 75 | }) | |
| 49 | 76 | return out | |
| 50 | 77 | } | |
| 51 | 78 | ||
| 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 | |
| 59 | 91 | } | |
| 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 | |
| 62 | 96 | } | |
| 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 | |
| 65 | 101 | } | |
| 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 | + | } | |
| 69 | 112 | } | |
| 70 | 113 | 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 | 137 | 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 | |
| 77 | 141 | } | |
| 78 | - | return p | |
| 142 | + | return positions | |
| 79 | 143 | } | |
| 80 | 144 | ||
| 81 | 145 | 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.Command | 14 | Command command.Command | ||
| 12 | Positions []int | 15 | Positions []int | ||
| 13 | rank int | 16 | rank int | ||
| @@ -16,18 +19,39 @@ type Match struct { | |||||
| 16 | order int | 19 | 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 out | 45 | return out | ||
| 26 | } | 46 | } | ||
| 47 | + | ||||
| 27 | var out []Match | 48 | 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.first | 66 | 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.Code | 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 | |||
| 46 | } | 73 | } | ||
| 47 | return a.order < b.order | 74 | return a.order < b.order | ||
| 48 | }) | 75 | }) | ||
| 49 | return out | 76 | 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}, true | 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 | |||
| 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}, true | 93 | + | 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}, true | 98 | + | 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}, true | 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 | + | } | |||
| 69 | } | 112 | } | ||
| 70 | return Match{}, false | 113 | 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] = i | 140 | + | positions[i] = i |
| 77 | } | 141 | } | ||
| 78 | - | return p | 142 | + | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 8 | "github.com/bnema/vev/internal/usecase/command" | |
| 7 | 9 | "github.com/stretchr/testify/require" | |
| 8 | 10 | ) | |
| @@ -71,3 +73,58 @@ func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |||
| 71 | 73 | ||
| 72 | 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 | + | } | |
| @@ -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 { | |||
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | type Model struct { | |
| 26 | + | results []Result | |
| 26 | 27 | commands []command.Command | |
| 27 | 28 | input ui.TextInput | |
| 28 | 29 | matches []Match | |
| @@ -30,13 +31,31 @@ type Model struct { | |||
| 30 | 31 | scroll int | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 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 | + | } | |
| 35 | 43 | m.refresh() | |
| 36 | 44 | 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 | 60 | func DefaultRenderStyles() RenderStyles { | |
| 42 | 61 | selection := renderer.DefaultStyle() | |
| @@ -49,16 +68,14 @@ func DefaultRenderStyles() RenderStyles { | |||
| 49 | 68 | func (m *Model) Insert(r rune) { | |
| 50 | 69 | if m != nil { | |
| 51 | 70 | m.input.Insert(r) | |
| 52 | - | m.selected = 0 | |
| 53 | - | m.scroll = 0 | |
| 71 | + | m.selected, m.scroll = 0, 0 | |
| 54 | 72 | m.refresh() | |
| 55 | 73 | } | |
| 56 | 74 | } | |
| 57 | 75 | func (m *Model) Backspace() { | |
| 58 | 76 | if m != nil && m.input.Value() != "" { | |
| 59 | 77 | m.input.Backspace() | |
| 60 | - | m.selected = 0 | |
| 61 | - | m.scroll = 0 | |
| 78 | + | m.selected, m.scroll = 0, 0 | |
| 62 | 79 | m.refresh() | |
| 63 | 80 | } | |
| 64 | 81 | } | |
| @@ -81,26 +98,32 @@ func (m *Model) Down() { | |||
| 81 | 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 | 104 | if m == nil || m.selected < 0 || m.selected >= len(m.matches) { | |
| 86 | - | return command.Command{}, false | |
| 105 | + | return nil, false | |
| 87 | 106 | } | |
| 88 | - | return m.matches[m.selected].Command, true | |
| 107 | + | return m.matches[m.selected].Result, true | |
| 89 | 108 | } | |
| 90 | 109 | ||
| 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. | |
| 93 | 112 | func (m *Model) CompleteSelected() bool { | |
| 94 | 113 | selected, ok := m.Selected() | |
| 95 | 114 | if !ok { | |
| 96 | 115 | return false | |
| 97 | 116 | } | |
| 117 | + | cmd, ok := resultCommand(selected) | |
| 118 | + | if !ok { | |
| 119 | + | return false | |
| 120 | + | } | |
| 98 | 121 | ||
| 99 | 122 | query := m.Query() | |
| 100 | - | completed := selected.Code | |
| 101 | - | if selected.Arguments == command.ArgumentsRequired { | |
| 123 | + | completed := cmd.Code | |
| 124 | + | if cmd.Arguments == command.ArgumentsRequired { | |
| 102 | 125 | token, args, hasSeparator := completionParts(query) | |
| 103 | - | if token == selected.Code && hasSeparator && args != "" { | |
| 126 | + | if token == cmd.Code && hasSeparator && args != "" { | |
| 104 | 127 | return false | |
| 105 | 128 | } | |
| 106 | 129 | completed += " " | |
| @@ -113,8 +136,7 @@ func (m *Model) CompleteSelected() bool { | |||
| 113 | 136 | } | |
| 114 | 137 | ||
| 115 | 138 | m.input.SetValue(completed) | |
| 116 | - | m.selected = 0 | |
| 117 | - | m.scroll = 0 | |
| 139 | + | m.selected, m.scroll = 0, 0 | |
| 118 | 140 | m.refresh() | |
| 119 | 141 | return true | |
| 120 | 142 | } | |
| @@ -136,7 +158,7 @@ func completionParts(query string) (token, args string, hasSeparator bool) { | |||
| 136 | 158 | 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 | 162 | func (m *Model) ArgumentCommand() (command.Command, bool) { | |
| 141 | 163 | if m == nil { | |
| 142 | 164 | return command.Command{}, false | |
| @@ -158,8 +180,8 @@ func (m *Model) Matches() []Match { | |||
| 158 | 180 | ||
| 159 | 181 | func (m *Model) refresh() { | |
| 160 | 182 | 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 | |
| 163 | 185 | // arguments make ordinary fuzzy matching inapplicable. | |
| 164 | 186 | if cmd, ok := ArgumentCommand(m.commands, query); ok { | |
| 165 | 187 | m.prependMatch(cmd) | |
| @@ -169,7 +191,7 @@ func (m *Model) refresh() { | |||
| 169 | 191 | // the partial first token so Tab can complete them. | |
| 170 | 192 | token, _, hasSeparator := completionParts(query) | |
| 171 | 193 | if hasSeparator { | |
| 172 | - | for _, match := range Fuzzy(m.commands, token) { | |
| 194 | + | for _, match := range fuzzyResults(commandResults(m.commands), token) { | |
| 173 | 195 | if match.Command.Arguments == command.ArgumentsRequired { | |
| 174 | 196 | m.matches = append(m.matches, match) | |
| 175 | 197 | } | |
| @@ -190,12 +212,11 @@ func (m *Model) prependMatch(cmd command.Command) { | |||
| 190 | 212 | } | |
| 191 | 213 | 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 | 217 | func (m *Model) clamp() { | |
| 196 | 218 | if len(m.matches) == 0 { | |
| 197 | - | m.selected = -1 | |
| 198 | - | m.scroll = 0 | |
| 219 | + | m.selected, m.scroll = -1, 0 | |
| 199 | 220 | return | |
| 200 | 221 | } | |
| 201 | 222 | if m.selected < 0 { | |
| @@ -218,21 +239,15 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 218 | 239 | if frame.Width == 0 || frame.Height == 0 { | |
| 219 | 240 | return frame | |
| 220 | 241 | } | |
| 221 | - | base := renderer.DefaultStyle() | |
| 222 | - | selection := styles.Selection | |
| 223 | - | desc := styles.Description | |
| 242 | + | base, selection, desc := renderer.DefaultStyle(), styles.Selection, styles.Description | |
| 224 | 243 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 225 | 244 | if m == nil { | |
| 226 | 245 | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | |
| 227 | 246 | return frame | |
| 228 | 247 | } | |
| 229 | 248 | 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 { | |
| 236 | 251 | return frame | |
| 237 | 252 | } | |
| 238 | 253 | if m.selected < m.scroll { | |
| @@ -243,8 +258,8 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 243 | 258 | } | |
| 244 | 259 | codeWidth := 0 | |
| 245 | 260 | 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)) | |
| 248 | 263 | } | |
| 249 | 264 | } | |
| 250 | 265 | activeCmd, activeOK := m.ArgumentCommand() | |
| @@ -259,34 +274,60 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 259 | 274 | style = selection | |
| 260 | 275 | } | |
| 261 | 276 | 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 | |
| 266 | 280 | } | |
| 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 | |
| 269 | 296 | 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 { | |
| 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}) | |
| 282 | 300 | } | |
| 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}) | |
| 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 | 333 | 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.Command | 27 | commands []command.Command | ||
| 27 | input ui.TextInput | 28 | input ui.TextInput | ||
| 28 | matches []Match | 29 | matches []Match | ||
| @@ -30,13 +31,31 @@ type Model struct { | |||||
| 30 | scroll int | 31 | 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 m | 44 | 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 = 0 | 71 | + | 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 = 0 | 78 | + | 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{}, false | 105 | + | return nil, false |
| 87 | } | 106 | } | ||
| 88 | - | return m.matches[m.selected].Command, true | 107 | + | return m.matches[m.selected].Result, true |
| 89 | } | 108 | } | ||
| 90 | 109 | ||||
| 91 | - | // CompleteSelected replaces the query with the selected command's effective | 110 | + | // 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 false | 115 | 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.Code | 123 | + | 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 false | 127 | 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 = 0 | 139 | + | m.selected, m.scroll = 0, 0 |
| 117 | - | m.scroll = 0 | |||
| 118 | m.refresh() | 140 | m.refresh() | ||
| 119 | return true | 141 | return true | ||
| 120 | } | 142 | } | ||
| @@ -136,7 +158,7 @@ func completionParts(query string) (token, args string, hasSeparator bool) { | |||||
| 136 | return query, "", false | 158 | 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{}, false | 164 | 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 its | 184 | + | // 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 | return | 213 | 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 = -1 | 219 | + | m.selected, m.scroll = -1, 0 |
| 198 | - | m.scroll = 0 | |||
| 199 | return | 220 | 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 frame | 240 | 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 frame | 246 | return frame | ||
| 228 | } | 247 | } | ||
| 229 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | 248 | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | ||
| 230 | - | start := 1 | 249 | + | start, visible := 1, frame.Height-1 |
| 231 | - | visible := frame.Height - start | 250 | + | if visible <= 0 || len(m.matches) == 0 { |
| 232 | - | if visible <= 0 { | |||
| 233 | - | return frame | |||
| 234 | - | } | |||
| 235 | - | if len(m.matches) == 0 { | |||
| 236 | return frame | 251 | 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 := 0 | 259 | 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 = selection | 274 | 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 := 0 | 277 | + | 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 := style | 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 | |||
| 269 | cellStyle.Bold = true | 296 | 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.Desc | 301 | + | x++ |
| 284 | - | if opts.Guidance != "" && activeOK && match.Command.ContextHint != command.ContextHintNone && activeCmd.Code == match.Command.Code { | 302 | + | } |
| 285 | - | description = opts.Guidance | 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}) | |||
| 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "testing" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/command" | |
| @@ -20,24 +21,24 @@ func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | |||
| 20 | 21 | require.Equal(t, "", m.Query()) | |
| 21 | 22 | selected, ok := m.Selected() | |
| 22 | 23 | require.True(t, ok) | |
| 23 | - | require.Equal(t, "ABC", selected.Code) | |
| 24 | + | require.Equal(t, "ABC", selectedCommandCode(t, selected)) | |
| 24 | 25 | ||
| 25 | 26 | m.Down() | |
| 26 | 27 | m.Down() | |
| 27 | 28 | selected, ok = m.Selected() | |
| 28 | 29 | require.True(t, ok) | |
| 29 | - | require.Equal(t, "AXY", selected.Code) | |
| 30 | + | require.Equal(t, "AXY", selectedCommandCode(t, selected)) | |
| 30 | 31 | ||
| 31 | 32 | m.Insert('d') | |
| 32 | 33 | require.Equal(t, "d", m.Query()) | |
| 33 | 34 | selected, ok = m.Selected() | |
| 34 | 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 | 38 | m.Up() | |
| 38 | 39 | selected, ok = m.Selected() | |
| 39 | 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 | 43 | m.Backspace() | |
| 43 | 44 | require.Equal(t, "", m.Query()) | |
| @@ -87,7 +88,7 @@ func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) { | |||
| 87 | 88 | require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata") | |
| 88 | 89 | selected, ok := m.Selected() | |
| 89 | 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 | 94 | func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |
| @@ -102,7 +103,11 @@ func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) { | |||
| 102 | 103 | commands: func(zzz command.Command) []command.Command { | |
| 103 | 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 | 111 | Code: "ZZZ", | |
| 107 | 112 | Desc: "unmatched", | |
| 108 | 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 | 194 | func frameRow(frame renderer.Frame, y int) string { | |
| 183 | 195 | row := make([]rune, frame.Width) | |
| 184 | 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 | 369 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | |
| 323 | 370 | jrs := cmd("JRS", "Jump", "Jump to recent session") | |
| 324 | 371 | 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.ArgumentsRequired | 371 | 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 | + | } | |||