From b387bd57b814fd0b0c0cadff4b636b9537645305 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Tue, 26 May 2026 23:00:05 +0200 Subject: [PATCH 1/2] Smarter search In the command palette typing "the" will now put "Theme" first on the list, and typing "ses" will put "Sessions" first on the list Signed-off-by: Djordje Lukic --- pkg/tui/dialog/command_palette.go | 55 ++++++++++++++++++++++++-- pkg/tui/dialog/command_palette_test.go | 30 ++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/pkg/tui/dialog/command_palette.go b/pkg/tui/dialog/command_palette.go index b56de7227..ae1932142 100644 --- a/pkg/tui/dialog/command_palette.go +++ b/pkg/tui/dialog/command_palette.go @@ -1,6 +1,7 @@ package dialog import ( + "slices" "strings" "charm.land/bubbles/v2/key" @@ -124,11 +125,16 @@ func (d *commandPaletteDialog) filterCommands() { d.filtered = d.filtered[:0] for _, cat := range d.categories { for _, cmd := range cat.Commands { - if query == "" || matchesCommandQuery(cmd, query) { + if query == "" || commandQueryScore(cmd, query) < commandQueryNoMatch { d.filtered = append(d.filtered, cmd) } } } + if query != "" { + slices.SortStableFunc(d.filtered, func(a, b commands.Item) int { + return commandQueryScore(a, query) - commandQueryScore(b, query) + }) + } // Clearing the search returns the cursor to the top, matching the file // picker. Filtered queries preserve the cursor when still in range. @@ -138,6 +144,8 @@ func (d *commandPaletteDialog) filterCommands() { d.scrollview.SetScrollOffset(0) } +const commandQueryNoMatch = 1 << 30 + // matchesCommandQuery reports whether the given command matches the lowercase // query string by searching label, description, or slash command. The // category is intentionally excluded: category names act as section headers @@ -145,9 +153,48 @@ func (d *commandPaletteDialog) filterCommands() { // targeted queries (e.g. typing "session" would otherwise match every // command in the Session category). func matchesCommandQuery(cmd commands.Item, query string) bool { - return strings.Contains(strings.ToLower(cmd.Label), query) || - strings.Contains(strings.ToLower(cmd.Description), query) || - strings.Contains(strings.ToLower(cmd.SlashCommand), query) + return commandQueryScore(cmd, query) < commandQueryNoMatch +} + +func commandQueryScore(cmd commands.Item, query string) int { + label := strings.ToLower(cmd.Label) + description := strings.ToLower(cmd.Description) + slashCommand := strings.ToLower(cmd.SlashCommand) + + return min( + commandFieldQueryScore(label, query, 0), + commandFieldQueryScore(slashCommand, query, 100), + commandFieldQueryScore(strings.TrimPrefix(slashCommand, "/"), query, 100), + commandFieldQueryScore(description, query, 1000), + ) +} + +func commandFieldQueryScore(value, query string, base int) int { + if value == "" { + return commandQueryNoMatch + } + if value == query { + return base + } + if strings.HasPrefix(value, query) { + return base + 10 + } + index := strings.Index(value, query) + if index < 0 { + return commandQueryNoMatch + } + if isCommandQueryWordStart(value, index) { + return base + 100 + index + } + return base + 200 + index +} + +func isCommandQueryWordStart(value string, index int) bool { + if index == 0 { + return true + } + previous := value[index-1] + return previous == ' ' || previous == '-' || previous == '_' || previous == '/' || previous == '.' } // buildList builds the visual list of commands grouped by category, with a diff --git a/pkg/tui/dialog/command_palette_test.go b/pkg/tui/dialog/command_palette_test.go index f95d3f551..78717561c 100644 --- a/pkg/tui/dialog/command_palette_test.go +++ b/pkg/tui/dialog/command_palette_test.go @@ -86,6 +86,36 @@ func TestCommandPaletteFilteringIgnoresCategory(t *testing.T) { "typing 'session' must not surface unrelated commands like 'Attach' just because they share the Session category") } +func TestCommandPaletteFilteringRanksLabelPrefixFirst(t *testing.T) { + cats := []commands.Category{ + { + Name: "Session", + Commands: []commands.Item{ + {ID: "session.attach", Label: "Attach", SlashCommand: "/attach", Description: "Attach a file to your message", Category: "Session"}, + {ID: "session.history", Label: "Sessions", SlashCommand: "/sessions", Description: "Browse and load past sessions", Category: "Session"}, + }, + }, + { + Name: "Settings", + Commands: []commands.Item{ + {ID: "settings.theme", Label: "Theme", SlashCommand: "/theme", Description: "Change the color theme", Category: "Settings"}, + }, + }, + } + dialog := NewCommandPaletteDialog(cats) + d := dialog.(*commandPaletteDialog) + + d.textInput.SetValue("the") + d.filterCommands() + + var ids []string + for _, c := range d.filtered { + ids = append(ids, c.ID) + } + require.Equal(t, []string{"settings.theme", "session.attach"}, ids, + "label prefix matches should rank ahead of description matches") +} + func TestCommandPaletteFiltering(t *testing.T) { dialog := NewCommandPaletteDialog(categories) d := dialog.(*commandPaletteDialog) From a5c3e6e7827c41ca96eb89df29e495bce0a89e84 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Tue, 26 May 2026 21:13:51 +0000 Subject: [PATCH 2/2] fix: address review on command palette filtering - Remove unused matchesCommandQuery wrapper (lint: unused) - Fix TestCommandPaletteFilteringRanksLabelPrefixFirst: session.attach description now contains "the" so it actually matches the query - isCommandQueryWordStart: decode previous rune via utf8 so non-ASCII characters preceding the match are handled correctly --- pkg/tui/dialog/command_palette.go | 27 ++++++++++++++------------ pkg/tui/dialog/command_palette_test.go | 2 +- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/pkg/tui/dialog/command_palette.go b/pkg/tui/dialog/command_palette.go index ae1932142..39d4c77a1 100644 --- a/pkg/tui/dialog/command_palette.go +++ b/pkg/tui/dialog/command_palette.go @@ -3,6 +3,8 @@ package dialog import ( "slices" "strings" + "unicode" + "unicode/utf8" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" @@ -146,16 +148,13 @@ func (d *commandPaletteDialog) filterCommands() { const commandQueryNoMatch = 1 << 30 -// matchesCommandQuery reports whether the given command matches the lowercase -// query string by searching label, description, or slash command. The -// category is intentionally excluded: category names act as section headers -// and matching them would surface every command in a category, drowning out -// targeted queries (e.g. typing "session" would otherwise match every -// command in the Session category). -func matchesCommandQuery(cmd commands.Item, query string) bool { - return commandQueryScore(cmd, query) < commandQueryNoMatch -} - +// commandQueryScore returns a relevance score for matching the given command +// against the lowercase query string by searching label, slash command, or +// description. Lower scores indicate stronger matches; commandQueryNoMatch +// means no match. The category is intentionally excluded: category names act +// as section headers and matching them would surface every command in a +// category, drowning out targeted queries (e.g. typing "session" would +// otherwise match every command in the Session category). func commandQueryScore(cmd commands.Item, query string) int { label := strings.ToLower(cmd.Label) description := strings.ToLower(cmd.Description) @@ -193,8 +192,12 @@ func isCommandQueryWordStart(value string, index int) bool { if index == 0 { return true } - previous := value[index-1] - return previous == ' ' || previous == '-' || previous == '_' || previous == '/' || previous == '.' + previous, _ := utf8.DecodeLastRuneInString(value[:index]) + switch previous { + case ' ', '-', '_', '/', '.': + return true + } + return unicode.IsSpace(previous) || unicode.IsPunct(previous) } // buildList builds the visual list of commands grouped by category, with a diff --git a/pkg/tui/dialog/command_palette_test.go b/pkg/tui/dialog/command_palette_test.go index 78717561c..a9f926cbe 100644 --- a/pkg/tui/dialog/command_palette_test.go +++ b/pkg/tui/dialog/command_palette_test.go @@ -91,7 +91,7 @@ func TestCommandPaletteFilteringRanksLabelPrefixFirst(t *testing.T) { { Name: "Session", Commands: []commands.Item{ - {ID: "session.attach", Label: "Attach", SlashCommand: "/attach", Description: "Attach a file to your message", Category: "Session"}, + {ID: "session.attach", Label: "Attach", SlashCommand: "/attach", Description: "Attach a file to the current message", Category: "Session"}, {ID: "session.history", Label: "Sessions", SlashCommand: "/sessions", Description: "Browse and load past sessions", Category: "Session"}, }, },