From f13c8cd44dbe1caeaf6a78fbc33ad304b9369fe2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:48:15 +0000 Subject: [PATCH] Allow optionally showing unmapped actions in the command palette Also highlight letters matching the search query. Fixes #9589 --- kittens/command_palette/main.go | 229 +++++++++++++++++++++------ kittens/command_palette/main.py | 36 ++++- kittens/command_palette/main_test.go | 146 +++++++++++++++-- kitty/boss.py | 16 +- kitty/options/utils.py | 1 + kitty_tests/command_palette.py | 59 +++++++ 6 files changed, 424 insertions(+), 63 deletions(-) diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go index bb1b59cab..218b5afb3 100644 --- a/kittens/command_palette/main.go +++ b/kittens/command_palette/main.go @@ -38,10 +38,16 @@ type InputData struct { CategoryOrder map[string][]string `json:"category_order"` } -// DisplayItem wraps a binding with its search text for FZF scoring +// DisplayItem wraps a binding with its per-column search texts for FZF scoring type DisplayItem struct { - binding Binding - searchText string // key + action_display + category for FZF + binding Binding + colTexts [3]string // [0]=key, [1]=action_display, [2]=category +} + +// matchInfo stores which column matched and the matched character positions +type matchInfo struct { + colIdx int // which column matched: 0=key, 1=action_display, 2=category + positions []int // rune positions in the matched column text } type displayLine struct { @@ -53,6 +59,9 @@ type displayLine struct { const maxKeyDisplayWidth = 30 +// unmappedLabel is shown in the key column for actions with no keyboard shortcut. +const unmappedLabel = "(unmapped)" + // truncateToWidth truncates s to fit within maxWidth cells, appending "..." if // truncated and maxWidth > 3. When maxWidth <= 3, the string is simply trimmed // to fit without appending ellipsis (no room for it). @@ -78,9 +87,9 @@ type Handler struct { lp *loop.Loop screen_size loop.ScreenSize all_items []DisplayItem - search_texts []string // parallel to all_items, for FZF scoring matcher *fzf.FuzzyMatcher - filtered_idx []int // indices into all_items for current results + filtered_idx []int // indices into all_items for current results + match_infos []matchInfo // parallel to filtered_idx, valid when query != "" query string selected_idx int scroll_offset int @@ -176,13 +185,13 @@ func (h *Handler) flattenBindings() { b.Category = catName b.Mode = modeName b.IsMouse = false - searchText := b.Key + " " + b.ActionDisplay + " " + catName - if modeName != "" { - searchText += " " + modeName + keyText := b.Key + if keyText == "" { + keyText = unmappedLabel } h.all_items = append(h.all_items, DisplayItem{ - binding: b, - searchText: searchText, + binding: b, + colTexts: [3]string{keyText, b.ActionDisplay, catName}, }) } } @@ -193,18 +202,11 @@ func (h *Handler) flattenBindings() { b.Category = "Mouse actions" b.Mode = "" b.IsMouse = true - searchText := b.Key + " " + b.ActionDisplay + " Mouse" h.all_items = append(h.all_items, DisplayItem{ - binding: b, - searchText: searchText, + binding: b, + colTexts: [3]string{b.Key, b.ActionDisplay, "Mouse actions"}, }) } - - // Build parallel search texts array for FZF - h.search_texts = make([]string, len(h.all_items)) - for i, item := range h.all_items { - h.search_texts[i] = item.searchText - } } func (h *Handler) updateFilter() { @@ -214,34 +216,99 @@ func (h *Handler) updateFilter() { for i := range h.all_items { h.filtered_idx[i] = i } - } else { - results, err := h.matcher.Score(h.search_texts, h.query) - if err != nil { - h.filtered_idx = nil - return + h.match_infos = nil + h.selected_idx = 0 + h.scroll_offset = 0 + return + } + + nItems := len(h.all_items) + + // Build per-column text slices for batch FZF scoring + colSlices := [3][]string{ + make([]string, nItems), + make([]string, nItems), + make([]string, nItems), + } + for i, item := range h.all_items { + colSlices[0][i] = item.colTexts[0] + colSlices[1][i] = item.colTexts[1] + colSlices[2][i] = item.colTexts[2] + } + + // Score each column independently + colResults := [3][]fzf.Result{} + for c := 0; c < 3; c++ { + results, err := h.matcher.Score(colSlices[c], h.query) + if err == nil { + colResults[c] = results } - type scored struct { - idx int - score uint - } - var matches []scored - for i, r := range results { - if r.Score > 0 { - matches = append(matches, scored{idx: i, score: r.Score}) + } + + type scored struct { + idx int + score uint + colIdx int + positions []int + } + var matches []scored + for i := range h.all_items { + bestScore := uint(0) + bestCol := 0 + var bestPositions []int + for c := 0; c < 3; c++ { + if colResults[c] != nil && i < len(colResults[c]) && colResults[c][i].Score > bestScore { + bestScore = colResults[c][i].Score + bestCol = c + bestPositions = colResults[c][i].Positions } } - sort.Slice(matches, func(i, j int) bool { - return matches[i].score > matches[j].score - }) - h.filtered_idx = make([]int, len(matches)) - for i, m := range matches { - h.filtered_idx[i] = m.idx + if bestScore > 0 { + matches = append(matches, scored{idx: i, score: bestScore, colIdx: bestCol, positions: bestPositions}) } } + sort.Slice(matches, func(i, j int) bool { + return matches[i].score > matches[j].score + }) + h.filtered_idx = make([]int, len(matches)) + h.match_infos = make([]matchInfo, len(matches)) + for i, m := range matches { + h.filtered_idx[i] = m.idx + h.match_infos[i] = matchInfo{colIdx: m.colIdx, positions: m.positions} + } h.selected_idx = 0 h.scroll_offset = 0 } +// highlightMatchedChars returns a string with characters at the given rune +// positions rendered using matchStyle, and the rest rendered using baseStyle +// (or unstyled if baseStyle is empty). +func (h *Handler) highlightMatchedChars(text string, positions []int, baseStyle, matchStyle string) string { + if len(positions) == 0 { + if baseStyle != "" { + return h.lp.SprintStyled(baseStyle, text) + } + return text + } + posSet := make(map[int]bool, len(positions)) + for _, p := range positions { + posSet[p] = true + } + runes := []rune(text) + var sb strings.Builder + for i, r := range runes { + ch := string(r) + if posSet[i] { + sb.WriteString(h.lp.SprintStyled(matchStyle, ch)) + } else if baseStyle != "" { + sb.WriteString(h.lp.SprintStyled(baseStyle, ch)) + } else { + sb.WriteString(ch) + } + } + return sb.String() +} + func (h *Handler) selectedBinding() *Binding { if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) { return nil @@ -368,8 +435,12 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) { }) } - // Binding line - keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth) + // Binding line — key column shows "(unmapped)" for actions with no shortcut + keyDisplay := b.Key + if keyDisplay == "" { + keyDisplay = unmappedLabel + } + keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth) lines = append(lines, displayLine{ text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay), itemIdx: fi, @@ -381,11 +452,22 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) { } func (h *Handler) drawFlatResults(startY, maxRows, width int) { + if len(h.filtered_idx) == 0 { + h.lp.MoveCursorTo(1, startY) + h.lp.QueueWriteString(h.lp.SprintStyled("italic dim", " No matches found")) + h.display_lines = []displayLine{} + return + } + var lines []displayLine for fi, idx := range h.filtered_idx { item := &h.all_items[idx] b := &item.binding - keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth) + keyDisplay := b.Key + if keyDisplay == "" { + keyDisplay = unmappedLabel + } + keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth) catSuffix := "" if b.Mode != "" { catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category) @@ -473,15 +555,64 @@ func (h *Handler) drawBindingLine(text string, filteredIdx, width int) { } b := &h.all_items[idx].binding - // Style the key portion green, leave action unstyled - keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth) - keyPrefix := fmt.Sprintf(" %-*s", maxKeyDisplayWidth, keyDisplay) - rest := "" - if len(text) > len(keyPrefix) { - rest = text[len(keyPrefix):] + // Build the key display (using unmappedLabel for items with no shortcut) + rawKey := b.Key + if rawKey == "" { + rawKey = unmappedLabel + } + keyDisplay := truncateToWidth(rawKey, maxKeyDisplayWidth) + + // Determine match info for highlighting (only set when a query is active) + var mi *matchInfo + if h.query != "" && filteredIdx < len(h.match_infos) { + mi = &h.match_infos[filteredIdx] + } + + const matchStyle = "fg=bright-yellow" + const keyStyle = "fg=green" + const unmappedStyle = "dim fg=green" + + // Render key column (4-space indent + key padded to maxKeyDisplayWidth + space) + paddingLen := max(0, maxKeyDisplayWidth-wcswidth.Stringwidth(keyDisplay)) + if mi != nil && mi.colIdx == 0 { + ks := keyStyle + if b.Key == "" { + ks = unmappedStyle + } + h.lp.QueueWriteString(" ") + h.lp.QueueWriteString(h.highlightMatchedChars(keyDisplay, mi.positions, ks, matchStyle)) + h.lp.QueueWriteString(strings.Repeat(" ", paddingLen) + " ") + } else if b.Key == "" { + h.lp.QueueWriteString(h.lp.SprintStyled(unmappedStyle, " "+keyDisplay+strings.Repeat(" ", paddingLen)+" ")) + } else { + h.lp.QueueWriteString(h.lp.SprintStyled(keyStyle, " "+keyDisplay+strings.Repeat(" ", paddingLen)+" ")) + } + + // Render action display column + if mi != nil && mi.colIdx == 1 { + h.lp.QueueWriteString(h.highlightMatchedChars(b.ActionDisplay, mi.positions, "", matchStyle)) + } else { + h.lp.QueueWriteString(b.ActionDisplay) + } + + // Render category suffix (only present in flat / search-results mode) + if h.query != "" { + if mi != nil && mi.colIdx == 2 { + if b.Mode != "" { + h.lp.QueueWriteString(fmt.Sprintf(" [%s/", b.Mode)) + } else { + h.lp.QueueWriteString(" [") + } + h.lp.QueueWriteString(h.highlightMatchedChars(b.Category, mi.positions, "", matchStyle)) + h.lp.QueueWriteString("]") + } else { + if b.Mode != "" { + h.lp.QueueWriteString(fmt.Sprintf(" [%s/%s]", b.Mode, b.Category)) + } else { + h.lp.QueueWriteString(fmt.Sprintf(" [%s]", b.Category)) + } + } } - h.lp.QueueWriteString(h.lp.SprintStyled("fg=green", keyPrefix)) - h.lp.QueueWriteString(rest) } // rowToFilteredIdx converts a 0-indexed cell Y coordinate to a filtered item diff --git a/kittens/command_palette/main.py b/kittens/command_palette/main.py index 2dfe389d5..4d9b418fa 100644 --- a/kittens/command_palette/main.py +++ b/kittens/command_palette/main.py @@ -10,7 +10,7 @@ from ..tui.handler import result_handler -def collect_keys_data(opts: Any) -> dict[str, Any]: +def collect_keys_data(opts: Any, show_unmapped: bool = False) -> dict[str, Any]: """Collect all keybinding data from options into a JSON-serializable dict.""" from kitty.actions import get_all_actions, groups from kitty.options.utils import KeyDefinition @@ -97,6 +97,34 @@ def as_sc(k: 'Any', v: KeyDefinition) -> Shortcut: new_default_cats[cat_name] = keep modes[''] = new_default_cats + # Optionally add unmapped actions (actions with no keyboard shortcut). + if show_unmapped: + # Collect all action names that already appear in a binding. + mapped_actions: set[str] = set() + for mode_cats in modes.values(): + for bindings in mode_cats.values(): + for b in bindings: + mapped_actions.add(b['action']) + + default_mode_cats = modes.setdefault('', {}) + for group_key, actions in get_all_actions().items(): + category = groups[group_key] + for action in actions: + if action.name not in mapped_actions: + default_mode_cats.setdefault(category, []).append({ + 'key': '', + 'action': action.name, + 'action_display': action.name, + 'definition': action.name, + 'help': action.short_help, + 'long_help': action.long_help, + }) + + # Re-sort each category: mapped entries (non-empty key) by key first, + # then unmapped entries (empty key) sorted by action name. + for cat in default_mode_cats: + default_mode_cats[cat].sort(key=lambda b: (b['key'] == '', b['key'] or b['action'])) + # Emit explicit mode and category ordering since JSON maps lose insertion order mode_order = list(modes.keys()) category_order: dict[str, list[str]] = {} @@ -131,7 +159,11 @@ def handle_result(args: list[str], data: dict[str, Any], target_window_id: int, help_text = 'Browse and trigger keyboard shortcuts and actions' usage = '' -OPTIONS = ''.format +OPTIONS = r''' +--show-unmapped +type=bool-set +Also show actions that have not been mapped to a keyboard shortcut. +'''.format if __name__ == '__main__': diff --git a/kittens/command_palette/main_test.go b/kittens/command_palette/main_test.go index e3316e9f1..41ff880f3 100644 --- a/kittens/command_palette/main_test.go +++ b/kittens/command_palette/main_test.go @@ -152,13 +152,17 @@ func TestFilterMatchesSubset(t *testing.T) { if len(h.filtered_idx) >= len(h.all_items) { t.Fatal("Expected fewer matches than total items") } - // Verify all returned items actually contain relevant text + // Verify all returned items contain relevant text in at least one column for _, idx := range h.filtered_idx { - text := strings.ToLower(h.all_items[idx].searchText) - if !strings.Contains(text, "clipboard") { - // FZF does fuzzy matching, so this is a soft check — - // the characters should at least be present + item := &h.all_items[idx] + found := false + for _, col := range item.colTexts { + if strings.Contains(strings.ToLower(col), "clipboard") { + found = true + break + } } + _ = found // FZF does fuzzy matching, so this is a soft check } } @@ -234,11 +238,16 @@ func TestSelectedBindingNilWhenOverflowIndex(t *testing.T) { func TestSearchTextContainsKeyAndAction(t *testing.T) { h := newTestHandler() for i, item := range h.all_items { - if !strings.Contains(item.searchText, item.binding.Key) { - t.Fatalf("Item %d: search text %q should contain key %q", i, item.searchText, item.binding.Key) + // colTexts[0] = key (or unmappedLabel for empty key), [1] = action_display, [2] = category + expectedKey := item.binding.Key + if expectedKey == "" { + expectedKey = unmappedLabel } - if !strings.Contains(item.searchText, item.binding.ActionDisplay) { - t.Fatalf("Item %d: search text %q should contain action %q", i, item.searchText, item.binding.ActionDisplay) + if !strings.Contains(item.colTexts[0], expectedKey) { + t.Fatalf("Item %d: colTexts[0] %q should contain key %q", i, item.colTexts[0], expectedKey) + } + if !strings.Contains(item.colTexts[1], item.binding.ActionDisplay) { + t.Fatalf("Item %d: colTexts[1] %q should contain action %q", i, item.colTexts[1], item.binding.ActionDisplay) } } } @@ -549,3 +558,122 @@ func TestScrollAdjustRevealsSectionHeader(t *testing.T) { t.Fatalf("Expected scroll_offset=0 to show category header, got %d", h.scroll_offset) } } + +func TestColTextsPopulated(t *testing.T) { + h := newTestHandler() + for i, item := range h.all_items { + if item.binding.IsMouse { + continue + } + expectedKey := item.binding.Key + if expectedKey == "" { + expectedKey = unmappedLabel + } + if item.colTexts[0] != expectedKey { + t.Fatalf("Item %d: colTexts[0]=%q expected %q", i, item.colTexts[0], expectedKey) + } + if item.colTexts[1] != item.binding.ActionDisplay { + t.Fatalf("Item %d: colTexts[1]=%q expected %q", i, item.colTexts[1], item.binding.ActionDisplay) + } + if item.colTexts[2] != item.binding.Category { + t.Fatalf("Item %d: colTexts[2]=%q expected %q", i, item.colTexts[2], item.binding.Category) + } + } +} + +func TestFilterSingleColumnMatch(t *testing.T) { + // "scroll" is in action_display column only, not in key or category. + // With per-column matching it should still match the action column. + h := newTestHandler() + h.query = "scroll" + h.updateFilter() + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for 'scroll' against action column") + } + // All matched items should have 'scroll' in exactly one column, not spread across columns + for _, idx := range h.filtered_idx { + item := &h.all_items[idx] + colMatch := false + for _, col := range item.colTexts { + if strings.Contains(strings.ToLower(col), "scroll") { + colMatch = true + break + } + } + _ = colMatch // FZF does fuzzy matching; at least the characters should appear in one column + } +} + +func TestFilterMatchInfosParallelToFilteredIdx(t *testing.T) { + h := newTestHandler() + h.query = "clipboard" + h.updateFilter() + if len(h.filtered_idx) == 0 { + t.Fatal("Expected some matches") + } + if len(h.match_infos) != len(h.filtered_idx) { + t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx)) + } + for i, mi := range h.match_infos { + if mi.colIdx < 0 || mi.colIdx > 2 { + t.Fatalf("match_infos[%d].colIdx=%d out of range [0,2]", i, mi.colIdx) + } + } +} + +func TestFilterMatchInfosNilWhenNoQuery(t *testing.T) { + h := newTestHandler() + h.query = "" + h.updateFilter() + if h.match_infos != nil { + t.Fatal("Expected match_infos to be nil when query is empty") + } +} + +func TestUnmappedActionDisplayed(t *testing.T) { + // Inject an item with an empty key (unmapped action) and verify display + h := &Handler{} + unmappedJSON := `{ + "modes": { + "": { + "Miscellaneous": [ + {"key": "ctrl+n", "action": "new_window", "action_display": "new_window", "definition": "new_window", "help": "Open new window", "long_help": ""}, + {"key": "", "action": "scroll_home", "action_display": "scroll_home", "definition": "scroll_home", "help": "Scroll to top", "long_help": ""} + ] + } + }, + "mouse": [], + "mode_order": [""], + "category_order": {"": ["Miscellaneous"]} + }` + if err := json.Unmarshal([]byte(unmappedJSON), &h.input_data); err != nil { + t.Fatal(err) + } + h.flattenBindings() + h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) + + if len(h.all_items) != 2 { + t.Fatalf("Expected 2 items, got %d", len(h.all_items)) + } + // Find the unmapped item + var unmapped *DisplayItem + for i := range h.all_items { + if h.all_items[i].binding.Key == "" { + unmapped = &h.all_items[i] + break + } + } + if unmapped == nil { + t.Fatal("Expected to find unmapped item") + } + // colTexts[0] should be unmappedLabel, not empty + if unmapped.colTexts[0] != unmappedLabel { + t.Fatalf("Expected colTexts[0]=%q for unmapped item, got %q", unmappedLabel, unmapped.colTexts[0]) + } + // Should be searchable by action name + h.query = "scroll_home" + h.updateFilter() + if len(h.filtered_idx) == 0 { + t.Fatal("Expected unmapped action to be found by action name search") + } +} diff --git a/kitty/boss.py b/kitty/boss.py index 31e2b5a76..754577099 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2302,10 +2302,20 @@ def on_kitten_finish( def input_unicode_character(self) -> None: self.run_kitten_with_metadata('unicode_input', window=self.window_for_dispatch) - @ac('misc', 'Browse and trigger keyboard shortcuts and actions in a searchable overlay') - def command_palette(self) -> None: + @ac('misc', ''' + Browse and trigger keyboard shortcuts and actions in a searchable overlay. + + Optionally pass :code:`show_unmapped` to also show actions that have no keyboard shortcut assigned:: + + # show only mapped actions (default) + map ctrl+shift+p command_palette + # also show unmapped actions + map ctrl+shift+alt+p command_palette show_unmapped + ''') + def command_palette(self, *args: str) -> None: from kittens.command_palette.main import collect_keys_data - data = collect_keys_data(get_options()) + show_unmapped = 'show_unmapped' in args + data = collect_keys_data(get_options(), show_unmapped=show_unmapped) self.run_kitten_with_metadata('command-palette', input_data=json.dumps(data), window=self.window_for_dispatch) @ac( diff --git a/kitty/options/utils.py b/kitty/options/utils.py index ec777282d..0b7ecdd54 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -167,6 +167,7 @@ def detach_tab_parse(func: str, rest: str) -> FuncArgsType: @func_with_args( 'set_background_opacity', 'goto_layout', 'toggle_layout', 'toggle_tab', 'kitty_shell', 'show_kitty_doc', 'set_tab_title', 'push_keyboard_mode', 'dump_lines_with_attrs', 'set_window_title', 'simulate_color_scheme_preference_change', + 'command_palette', ) def simple_parse(func: str, rest: str) -> FuncArgsType: return func, (rest,) diff --git a/kitty_tests/command_palette.py b/kitty_tests/command_palette.py index da32c4931..00bec7ee5 100644 --- a/kitty_tests/command_palette.py +++ b/kitty_tests/command_palette.py @@ -98,3 +98,62 @@ def test_ordering_arrays_present(self): set(data['modes'][mode_name].keys()), f'category_order for mode {mode_name!r} should match modes keys', ) + + def test_show_unmapped_includes_extra_actions(self): + from kittens.command_palette.main import collect_keys_data + from kitty.actions import get_all_actions + opts = self.set_options() + data_default = collect_keys_data(opts, show_unmapped=False) + data_unmapped = collect_keys_data(opts, show_unmapped=True) + # With show_unmapped=True, we should have at least as many bindings + def count_bindings(data: dict) -> int: + total = 0 + for cats in data['modes'].values(): + for bindings in cats.values(): + total += len(bindings) + return total + count_default = count_bindings(data_default) + count_with_unmapped = count_bindings(data_unmapped) + self.assertTrue( + count_with_unmapped >= count_default, + 'show_unmapped should not remove any existing bindings', + ) + # There should be at least one unmapped action (empty key) in the result + found_unmapped = False + for cats in data_unmapped['modes'].values(): + for bindings in cats.values(): + for b in bindings: + if b['key'] == '': + found_unmapped = True + # Unmapped actions must still have action and definition + self.assertTrue(len(b['action']) > 0) + self.assertTrue(len(b['definition']) > 0) + break + self.assertTrue(found_unmapped, 'Expected at least one unmapped action') + + def test_show_unmapped_false_has_no_empty_keys(self): + from kittens.command_palette.main import collect_keys_data + opts = self.set_options() + data = collect_keys_data(opts, show_unmapped=False) + for cats in data['modes'].values(): + for bindings in cats.values(): + for b in bindings: + self.assertTrue( + len(b['key']) > 0, + f'Without show_unmapped, all bindings should have non-empty keys; got {b!r}', + ) + + def test_show_unmapped_sorted_order(self): + from kittens.command_palette.main import collect_keys_data + opts = self.set_options() + data = collect_keys_data(opts, show_unmapped=True) + # In each category, mapped bindings (non-empty key) should come before unmapped ones + for cat_name, bindings in data['modes'].get('', {}).items(): + seen_unmapped = False + for b in bindings: + if b['key'] == '': + seen_unmapped = True + elif seen_unmapped: + self.fail( + f'In category {cat_name!r}, mapped binding {b!r} follows an unmapped one' + )