From 3d89cb267c6d3b0219cac42cbd9dcffd8b0da341 Mon Sep 17 00:00:00 2001 From: Daniel M German Date: Mon, 6 Apr 2026 09:41:30 -0700 Subject: [PATCH] Add alias and combine support to the command palette Users who define action_alias or kitten_alias in kitty.conf had no way to discover or trigger these custom commands from the command palette. Aliased keybindings were miscategorized (landing in "Miscellaneous" with no help text), and combine bindings had the same problem. Changes: - Resolve aliases via opts.alias_map to get correct action names, categories, and help text for aliased keybindings - Add dedicated "Action aliases" and "Kitten aliases" sections that list all user-defined aliases, with bound aliases showing their key and unbound aliases browsable as unmapped entries - Add a "Combined actions" section for combine keybindings - Make alias names searchable in the Go TUI so users can find bindings by typing the alias name - Fix action column highlight positions to match the scored text, preventing visual corruption when searching for alias names Also refactors collect_keys_data into focused single-responsibility functions and reduces nesting depth across both Python and Go. --- kittens/command_palette/main.go | 139 +++++++++------ kittens/command_palette/main.py | 288 ++++++++++++++++++++++---------- kitty_tests/command_palette.py | 121 ++++++++++++-- 3 files changed, 404 insertions(+), 144 deletions(-) diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go index f0bfbe8f8..ae669f1c9 100644 --- a/kittens/command_palette/main.go +++ b/kittens/command_palette/main.go @@ -27,6 +27,7 @@ type Binding struct { Definition string `json:"definition"` Help string `json:"help"` LongHelp string `json:"long_help"` + Alias string `json:"alias"` Category string Mode string IsMouse bool @@ -276,6 +277,7 @@ type Handler struct { keyboard_shortcuts []*config.KeyAction } +// initialize sets up the TUI: screen size, cursor, cached settings, and initial data load. func (h *Handler) initialize() (string, error) { sz, err := h.lp.ScreenSize() if err != nil { @@ -305,6 +307,7 @@ func (h *Handler) initialize() (string, error) { return "", nil } +// loadData reads JSON input data from stdin and flattens it into display items. func (h *Handler) loadData() error { data, err := io.ReadAll(os.Stdin) if err != nil { @@ -321,6 +324,38 @@ func (h *Handler) loadData() error { return nil } +// bindingToDisplayItem converts a Binding into a DisplayItem with pre-tokenized +// words for word-level matching. +func bindingToDisplayItem(b Binding) DisplayItem { + keyText := b.Key + if keyText == "" { + keyText = unmappedLabel + } + actionText := b.ActionDisplay + if b.Alias != "" { + actionText = b.Alias + " " + actionText + } + return DisplayItem{ + binding: b, + keyText: keyText, + actionText: actionText, + categoryText: b.Category, + keyWords: tokenizeWords(keyText), + actionWords: tokenizeWords(actionText), + categoryWords: tokenizeWords(b.Category), + } +} + +// flattenCategoryBindings appends all bindings from a single category to items. +func flattenCategoryBindings(bindings []Binding, catName, modeName string, items *[]DisplayItem) { + for _, b := range bindings { + b.Category = catName + b.Mode = modeName + b.IsMouse = false + *items = append(*items, bindingToDisplayItem(b)) + } +} + // flattenBindings converts the hierarchical mode/category/binding data into // a flat list suitable for display and word-level scoring. Uses the explicit // ordering arrays from Python since Go maps do not preserve insertion order. @@ -364,24 +399,7 @@ func (h *Handler) flattenBindings() { if !ok { continue } - for _, b := range bindings { - b.Category = catName - b.Mode = modeName - b.IsMouse = false - keyText := b.Key - if keyText == "" { - keyText = unmappedLabel - } - h.all_items = append(h.all_items, DisplayItem{ - binding: b, - keyText: keyText, - actionText: b.ActionDisplay, - categoryText: catName, - keyWords: tokenizeWords(keyText), - actionWords: tokenizeWords(b.ActionDisplay), - categoryWords: tokenizeWords(catName), - }) - } + flattenCategoryBindings(bindings, catName, modeName, &h.all_items) } } @@ -390,18 +408,11 @@ func (h *Handler) flattenBindings() { b.Category = "Mouse actions" b.Mode = "" b.IsMouse = true - h.all_items = append(h.all_items, DisplayItem{ - binding: b, - keyText: b.Key, - actionText: b.ActionDisplay, - categoryText: "Mouse actions", - keyWords: tokenizeWords(b.Key), - actionWords: tokenizeWords(b.ActionDisplay), - categoryWords: tokenizeWords("Mouse actions"), - }) + h.all_items = append(h.all_items, bindingToDisplayItem(b)) } } +// updateFilter rebuilds the filtered item list based on the current query. func (h *Handler) updateFilter() { tokens := tokenizeQuery(h.query) @@ -508,6 +519,7 @@ func (h *Handler) highlightMatchedChars(text string, positions []int, baseStyle, return sb.String() } +// selectedBinding returns the currently selected binding, or nil if none. func (h *Handler) selectedBinding() *Binding { if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) { return nil @@ -519,6 +531,7 @@ func (h *Handler) selectedBinding() *Binding { return &h.all_items[idx].binding } +// draw_screen renders the full palette UI: query input, help bar, and results. func (h *Handler) draw_screen() { h.lp.StartAtomicUpdate() defer h.lp.EndAtomicUpdate() @@ -580,6 +593,7 @@ func (h *Handler) draw_screen() { h.lp.MoveCursorTo(3+wcswidth.Stringwidth(h.query), searchBarY) } +// drawGroupedResults renders results organized by mode and category headers. func (h *Handler) drawGroupedResults(startY, maxRows, width int) { var lines []displayLine lastMode := "" @@ -619,7 +633,7 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) { // Binding line keyDisplay := keyDisplayText(b) lines = append(lines, displayLine{ - text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay), + text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, item.actionText), itemIdx: fi, }) } @@ -628,6 +642,7 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) { h.drawLines(lines, startY, maxRows, width) } +// drawFlatResults renders a flat list of scored results without category headers. func (h *Handler) drawFlatResults(startY, maxRows, width int) { if len(h.filtered_idx) == 0 { h.lp.MoveCursorTo(1, startY) @@ -648,7 +663,7 @@ func (h *Handler) drawFlatResults(startY, maxRows, width int) { catSuffix = fmt.Sprintf(" [%s]", b.Category) } lines = append(lines, displayLine{ - text: fmt.Sprintf(" %-*s %-30s%s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay, catSuffix), + text: fmt.Sprintf(" %-*s %-30s%s", maxKeyDisplayWidth, keyDisplay, item.actionText, catSuffix), itemIdx: fi, }) } @@ -657,6 +672,7 @@ func (h *Handler) drawFlatResults(startY, maxRows, width int) { h.drawLines(lines, startY, maxRows, width) } +// drawLines renders display lines within the visible scroll window. func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) { if maxRows <= 0 || len(lines) == 0 { return @@ -711,6 +727,38 @@ func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) { } } +// drawCategorySuffix renders the " [category]" or " [mode/category]" suffix +// with optional match highlighting. +func (h *Handler) drawCategorySuffix(b *Binding, mi *matchInfo, baseStyle, matchStyle string) { + styled := func(s string) string { + if baseStyle != "" { + return h.lp.SprintStyled(baseStyle, s) + } + return s + } + prefix := " [" + if b.Mode != "" { + prefix = fmt.Sprintf(" [%s/", b.Mode) + } + if mi != nil && len(mi.categoryPositions) > 0 { + h.lp.QueueWriteString(styled(prefix)) + h.lp.QueueWriteString(h.highlightMatchedChars(b.Category, mi.categoryPositions, baseStyle, matchStyle)) + h.lp.QueueWriteString(styled("]")) + } else { + h.lp.QueueWriteString(styled(prefix + b.Category + "]")) + } +} + +// categorySuffixWidth returns the display width of the category suffix. +func categorySuffixWidth(b *Binding) int { + w := 2 + wcswidth.Stringwidth(b.Category) + 1 // " [" + category + "]" + if b.Mode != "" { + w += wcswidth.Stringwidth(b.Mode) + 1 // mode + "/" + } + return w +} + +// drawBindingLine renders a single binding row with key, action, and optional category. func (h *Handler) drawBindingLine(filteredIdx, width int, isSelected bool) { if filteredIdx < 0 || filteredIdx >= len(h.filtered_idx) { return @@ -720,6 +768,7 @@ func (h *Handler) drawBindingLine(filteredIdx, width int, isSelected bool) { return } b := &h.all_items[idx].binding + actionDisplay := h.all_items[idx].actionText // Build the key display keyDisplay := keyDisplayText(b) @@ -768,35 +817,21 @@ func (h *Handler) drawBindingLine(filteredIdx, width int, isSelected bool) { // Render action display column if mi != nil && len(mi.actionPositions) > 0 { - h.lp.QueueWriteString(h.highlightMatchedChars(b.ActionDisplay, mi.actionPositions, baseStyle, matchStyle)) + h.lp.QueueWriteString(h.highlightMatchedChars(actionDisplay, mi.actionPositions, baseStyle, matchStyle)) } else { - h.lp.QueueWriteString(styled(b.ActionDisplay)) + h.lp.QueueWriteString(styled(actionDisplay)) } // Render category suffix (only present in flat / search-results mode) if h.query != "" { - prefix := " [" - if b.Mode != "" { - prefix = fmt.Sprintf(" [%s/", b.Mode) - } - if mi != nil && len(mi.categoryPositions) > 0 { - h.lp.QueueWriteString(styled(prefix)) - h.lp.QueueWriteString(h.highlightMatchedChars(b.Category, mi.categoryPositions, baseStyle, matchStyle)) - h.lp.QueueWriteString(styled("]")) - } else { - h.lp.QueueWriteString(styled(prefix + b.Category + "]")) - } + h.drawCategorySuffix(b, mi, baseStyle, matchStyle) } // For selected rows, pad the rest of the line with reverse video if isSelected { - // Calculate rendered width and pad to fill the line - rendered := 4 + wcswidth.Stringwidth(keyDisplay) + paddingLen + 1 + wcswidth.Stringwidth(b.ActionDisplay) + rendered := 4 + wcswidth.Stringwidth(keyDisplay) + paddingLen + 1 + wcswidth.Stringwidth(actionDisplay) if h.query != "" { - rendered += 2 + wcswidth.Stringwidth(b.Category) + 1 - if b.Mode != "" { - rendered += wcswidth.Stringwidth(b.Mode) + 1 - } + rendered += categorySuffixWidth(b) } if pad := width - rendered; pad > 0 { h.lp.QueueWriteString(h.lp.SprintStyled(baseStyle, strings.Repeat(" ", pad))) @@ -820,6 +855,7 @@ func (h *Handler) rowToFilteredIdx(cellY int) int { return h.display_lines[lineIdx].itemIdx } +// onMouseEvent handles mouse clicks to select and trigger items. func (h *Handler) onMouseEvent(ev *loop.MouseEvent) error { switch ev.Event_type { case loop.MOUSE_CLICK: @@ -840,6 +876,7 @@ func (h *Handler) onMouseEvent(ev *loop.MouseEvent) error { return nil } +// onKeyEvent handles keyboard input for navigation, selection, and query editing. func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error { if ev.MatchesPressOrRepeat("escape") { ev.Handled = true @@ -930,6 +967,7 @@ func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error { return nil } +// onText handles typed characters, appending them to the search query. func (h *Handler) onText(text string, from_key_event bool, in_bracketed_paste bool) error { h.query += text h.updateFilter() @@ -937,12 +975,14 @@ func (h *Handler) onText(text string, from_key_event bool, in_bracketed_paste bo return nil } +// onResize redraws the screen when the terminal is resized. func (h *Handler) onResize(old, new_size loop.ScreenSize) error { h.screen_size = new_size h.draw_screen() return nil } +// moveSelection moves the selected item by delta positions, clamping to bounds. func (h *Handler) moveSelection(delta int) { if len(h.filtered_idx) == 0 { return @@ -953,6 +993,7 @@ func (h *Handler) moveSelection(delta int) { h.draw_screen() } +// triggerSelected sets the selected binding's definition as the result and exits. func (h *Handler) triggerSelected() { b := h.selectedBinding() if b == nil || b.IsMouse { @@ -963,6 +1004,7 @@ func (h *Handler) triggerSelected() { h.lp.Quit(0) } +// main runs the command palette TUI as a kitty overlay. func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { if tty.IsTerminal(os.Stdin.Fd()) { return 1, fmt.Errorf("This kitten must only be run via the command_palette action mapped to a shortcut in kitty.conf") @@ -1005,6 +1047,7 @@ func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { return } +// EntryPoint registers the command palette subcommand on the parent CLI. func EntryPoint(parent *cli.Command) { create_cmd(parent, main) } diff --git a/kittens/command_palette/main.py b/kittens/command_palette/main.py index 682c144fe..b8890197f 100644 --- a/kittens/command_palette/main.py +++ b/kittens/command_palette/main.py @@ -1,6 +1,8 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2024, Kovid Goyal +# Entry point: collect_keys_data(opts) — collects all keybinding, alias, and +# mouse data into a JSON-serializable dict consumed by the Go TUI. import sys from functools import partial @@ -39,13 +41,31 @@ map('Move selection down', egr() # }}} -def collect_keys_data(opts: Any) -> 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 - from kitty.types import Shortcut +def classify_action( + definition: str, alias_map: Any, action_to_group: dict[str, str] +) -> tuple[str, str, str]: + """Classify a keybinding definition into (action_name, category, alias). + + Returns the resolved action name, the category it belongs to, and the alias + name if the definition uses one (empty string otherwise). + """ + raw_action = definition.split()[0] if definition else 'no_op' + if raw_action == 'combine': + return 'combine', 'Combined actions', '' + if raw_action in action_to_group or raw_action == 'no_op': + return raw_action, action_to_group.get(raw_action, 'Miscellaneous'), '' + # Not a known action — try alias resolution + resolved = alias_map.resolve_aliases(definition) + if resolved: + action_name = resolved[0].func + return action_name, 'Action aliases', raw_action + return raw_action, 'Miscellaneous', '' + + +def build_action_lookups() -> tuple[dict[str, str], dict[str, str], dict[str, str]]: + """Build action->group, action->help, and action->long_help lookups.""" + from kitty.actions import get_all_actions, groups - # Build action->group and action->help lookups action_to_group: dict[str, str] = {} action_to_help: dict[str, str] = {} action_to_long_help: dict[str, str] = {} @@ -54,80 +74,120 @@ def collect_keys_data(opts: Any) -> dict[str, Any]: action_to_group[action.name] = groups[group_key] action_to_help[action.name] = action.short_help action_to_long_help[action.name] = action.long_help + return action_to_group, action_to_help, action_to_long_help - modes: dict[str, dict[str, list[dict[str, str]]]] = {} + +def deduplicate_definitions(defns: list[Any]) -> list[Any]: + """Return unique definitions, keeping the last occurrence of each.""" + seen: set[tuple[Any, ...]] = set() + uniq: list[Any] = [] + for d in reversed(defns): + uid = d.unique_identity_within_keymap + if uid not in seen: + seen.add(uid) + uniq.append(d) + return uniq + + +def build_binding_entry( + key_repr: str, action_repr: str, definition: str, alias_map: Any, + action_to_group: dict[str, str], action_to_help: dict[str, str], + action_to_long_help: dict[str, str], +) -> tuple[dict[str, str], str]: + """Build a single binding entry dict and return it with its category.""" + action_name, category, alias = classify_action(definition, alias_map, action_to_group) + entry: dict[str, str] = { + 'key': key_repr, + 'action': action_name, + 'action_display': action_repr, + 'definition': definition or action_name, + 'help': action_to_help.get(action_name, ''), + 'long_help': action_to_long_help.get(action_name, ''), + } + if alias: + entry['alias'] = alias + return entry, category + + +def order_categories( + categories: dict[str, list[dict[str, str]]], group_order: list[str] +) -> dict[str, list[dict[str, str]]]: + """Order categories by the groups order, with remaining categories sorted at the end.""" + ordered: dict[str, list[dict[str, str]]] = {} + for group_title in group_order: + if group_title in categories: + ordered[group_title] = categories.pop(group_title) + for cat_name, binds in sorted(categories.items()): + ordered[cat_name] = binds + return ordered + + +def collect_keyboard_bindings( + opts: Any, + action_to_group: dict[str, str], + action_to_help: dict[str, str], + action_to_long_help: dict[str, str], +) -> dict[str, dict[str, list[dict[str, str]]]]: + """Collect keybindings from all keyboard modes into categorized dicts.""" + from kitty.actions import groups + from kitty.options.utils import KeyDefinition + from kitty.types import Shortcut def as_sc(k: 'Any', v: KeyDefinition) -> Shortcut: if v.is_sequence: return Shortcut((v.trigger,) + v.rest) return Shortcut((k,)) + modes: dict[str, dict[str, list[dict[str, str]]]] = {} for mode_name, mode in opts.keyboard_modes.items(): categories: dict[str, list[dict[str, str]]] = {} for key, defns in mode.keymap.items(): - # Use last non-duplicate definition - seen: set[tuple[Any, ...]] = set() - uniq: list[KeyDefinition] = [] - for d in reversed(defns): - uid = d.unique_identity_within_keymap - if uid not in seen: - seen.add(uid) - uniq.append(d) - for d in uniq: + for d in deduplicate_definitions(defns): sc = as_sc(key, d) - key_repr = sc.human_repr(opts.kitty_mod) - action_repr = d.human_repr() - # Determine category from first word of action definition - action_name = d.definition.split()[0] if d.definition else 'no_op' - category = action_to_group.get(action_name, 'Miscellaneous') - help_text = action_to_help.get(action_name, '') - long_help = action_to_long_help.get(action_name, '') - categories.setdefault(category, []).append({ - 'key': key_repr, - 'action': action_name, - 'action_display': action_repr, - 'definition': d.definition or action_name, - 'help': help_text, - 'long_help': long_help, - }) - # Sort within categories + entry, category = build_binding_entry( + sc.human_repr(opts.kitty_mod), d.human_repr(), + d.definition or '', opts.alias_map, + action_to_group, action_to_help, action_to_long_help) + categories.setdefault(category, []).append(entry) for cat in categories: categories[cat].sort(key=lambda b: b['key']) - # Order categories by the groups order - ordered: dict[str, list[dict[str, str]]] = {} - for group_title in groups.values(): - if group_title in categories: - ordered[group_title] = categories.pop(group_title) - # Add any remaining - for cat_name, binds in sorted(categories.items()): - ordered[cat_name] = binds - modes[mode_name] = ordered + modes[mode_name] = order_categories(categories, list(groups.values())) + return modes - # Move push_keyboard_mode bindings from the default mode into the - # respective keyboard mode's section so they appear alongside its shortcuts. - if '' in modes: - new_default_cats: dict[str, list[dict[str, str]]] = {} - for cat_name, bindings in modes[''].items(): - keep: list[dict[str, str]] = [] - for b in bindings: - if b['action'] == 'push_keyboard_mode': - parts = b['definition'].split() - target = parts[1] if len(parts) > 1 else '' - if target and target in modes: - if 'Enter mode' not in modes[target]: - new_target: dict[str, list[dict[str, str]]] = {'Enter mode': [b]} - new_target.update(modes[target]) - modes[target] = new_target - else: - modes[target]['Enter mode'].append(b) - continue - keep.append(b) - if keep: - new_default_cats[cat_name] = keep - modes[''] = new_default_cats - # Add unmapped actions (actions with no keyboard shortcut). - # Collect all action names that already appear in a binding. +def relocate_mode_entry_bindings( + modes: dict[str, dict[str, list[dict[str, str]]]] +) -> None: + """Move push_keyboard_mode bindings into their target mode's section.""" + if '' not in modes: + return + new_default_cats: dict[str, list[dict[str, str]]] = {} + for cat_name, bindings in modes[''].items(): + keep: list[dict[str, str]] = [] + for b in bindings: + if b['action'] == 'push_keyboard_mode': + parts = b['definition'].split() + target = parts[1] if len(parts) > 1 else '' + if target and target in modes: + if 'Enter mode' not in modes[target]: + new_target: dict[str, list[dict[str, str]]] = {'Enter mode': [b]} + new_target.update(modes[target]) + modes[target] = new_target + else: + modes[target]['Enter mode'].append(b) + continue + keep.append(b) + if keep: + new_default_cats[cat_name] = keep + modes[''] = new_default_cats + + +def add_unmapped_actions( + modes: dict[str, dict[str, list[dict[str, str]]]] +) -> None: + """Add actions with no keyboard shortcut to the default mode.""" + from kitty.actions import get_all_actions, groups + mapped_actions: set[str] = set() for mode_cats in modes.values(): for bindings in mode_cats.values(): @@ -148,38 +208,98 @@ def collect_keys_data(opts: Any) -> dict[str, Any]: '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. + # Re-sort: mapped entries (non-empty key) first, then unmapped by action name. for cat in default_mode_cats: default_mode_cats[cat].sort(key=lambda b: (b['key'] == '', b['key'] or b['action'])) - # Re-order default_mode_cats by groups ordering (adding unmapped actions may - # have appended new categories at the end, breaking the established order). - reordered: dict[str, list[dict[str, str]]] = {} - for group_title in groups.values(): - if group_title in default_mode_cats: - reordered[group_title] = default_mode_cats[group_title] - for cat_name, binds in default_mode_cats.items(): - if cat_name not in reordered: - reordered[cat_name] = binds - modes[''] = reordered + # Re-order by groups ordering + modes[''] = order_categories(default_mode_cats, list(groups.values())) - # Emit explicit mode and category ordering since JSON maps lose insertion order - mode_order = list(modes.keys()) - category_order: dict[str, list[str]] = {} - for mode_name, cats in modes.items(): - category_order[mode_name] = list(cats.keys()) - # Mouse mappings +def collect_bound_aliases( + modes: dict[str, dict[str, list[dict[str, str]]]] +) -> set[str]: + """Collect alias names that are already present from keybindings.""" + bound: set[str] = set() + for mode_cats in modes.values(): + for bindings in mode_cats.values(): + bound.update(b['alias'] for b in bindings if 'alias' in b) + return bound + + +def build_alias_entry(alias_map: Any, display: str, expansion: str) -> dict[str, str]: + """Build a single alias section entry.""" + resolved = alias_map.resolve_aliases(display) + resolved_action = resolved[0].func if resolved else display.split()[0] + return { + 'key': '', + 'action': resolved_action, + 'action_display': display, + 'definition': expansion, + 'help': f'Alias for: {expansion}', + 'long_help': '', + 'alias': display, + } + + +def add_alias_sections( + opts: Any, + modes: dict[str, dict[str, list[dict[str, str]]]] +) -> None: + """Add Action aliases and Kitten aliases sections for unbound aliases.""" + bound_aliases = collect_bound_aliases(modes) + + action_alias_entries: list[dict[str, str]] = [] + kitten_alias_entries: list[dict[str, str]] = [] + for alias_name, alias_list in opts.alias_map.aliases.items(): + for aa in alias_list: + if aa.replace_second_arg: + display = f'{alias_name} {aa.name}' + expansion = f'{alias_name} {aa.value}' + target_list = kitten_alias_entries + else: + display = aa.name + expansion = aa.value + target_list = action_alias_entries + if display not in bound_aliases: + target_list.append(build_alias_entry(opts.alias_map, display, expansion)) + + default_mode_cats = modes.setdefault('', {}) + sort_key = lambda b: (b['key'] == '', b['key'] or b['action_display']) + for section_name, entries in (('Action aliases', action_alias_entries), ('Kitten aliases', kitten_alias_entries)): + if entries: + existing = default_mode_cats.get(section_name, []) + existing.extend(entries) + existing.sort(key=sort_key) + default_mode_cats[section_name] = existing + + +def collect_mouse_bindings(opts: Any) -> list[dict[str, str]]: + """Collect mouse mappings.""" mouse: list[dict[str, str]] = [] for event, action in opts.mousemap.items(): key_repr = event.human_repr(opts.kitty_mod) mouse.append({'key': key_repr, 'action': action, 'action_display': action, 'help': '', 'long_help': ''}) mouse.sort(key=lambda b: b['key']) + return mouse + + +def collect_keys_data(opts: Any) -> dict[str, Any]: + """Collect all keybinding data from options into a JSON-serializable dict.""" + action_to_group, action_to_help, action_to_long_help = build_action_lookups() + modes = collect_keyboard_bindings(opts, action_to_group, action_to_help, action_to_long_help) + relocate_mode_entry_bindings(modes) + add_unmapped_actions(modes) + add_alias_sections(opts, modes) + + mode_order = list(modes.keys()) + category_order: dict[str, list[str]] = {} + for mode_name, cats in modes.items(): + category_order[mode_name] = list(cats.keys()) return { 'modes': modes, - 'mouse': mouse, + 'mouse': collect_mouse_bindings(opts), 'mode_order': mode_order, 'category_order': category_order, } diff --git a/kitty_tests/command_palette.py b/kitty_tests/command_palette.py index ce40e2fa0..6f5c4534c 100644 --- a/kitty_tests/command_palette.py +++ b/kitty_tests/command_palette.py @@ -17,8 +17,8 @@ class TestCommandPalette(BaseTest): default_mode = data['modes'][''] # Should have at least some categories self.assertTrue(len(default_mode) > 0, 'Should have at least one category') - # All category names should be from the known groups - known_titles = set(groups.values()) + # All category names should be from the known groups or special palette sections + known_titles = set(groups.values()) | {'Action aliases', 'Kitten aliases', 'Combined actions'} for cat_name in default_mode: self.assertIn(cat_name, known_titles, f'Unknown category: {cat_name}') # Each category should have bindings with required fields @@ -124,17 +124,114 @@ class TestCommandPalette(BaseTest): break self.assertTrue(found_unmapped, 'Expected at least one unmapped action to always be present') - def test_unmapped_actions_sorted_order(self): + def test_alias_resolution(self): from kittens.command_palette.main import collect_keys_data + from kitty.options.utils import ActionAlias, AliasMap, parse_map opts = self.set_options() + # Set up action aliases: launch_tab (bound) and launch_bg (unbound) + alias_map = AliasMap() + alias_map.append('launch_tab', ActionAlias('launch_tab', 'launch --type=tab --cwd=current')) + alias_map.append('launch_bg', ActionAlias('launch_bg', 'launch --type=background')) + opts.alias_map = alias_map + # Add a keybinding that uses the launch_tab alias + for kd in parse_map('f1 launch_tab vim'): + kd = kd.resolve_and_copy(opts.kitty_mod) + default_mode = opts.keyboard_modes[''] + default_mode.keymap.setdefault(kd.trigger, []).append(kd) + data = collect_keys_data(opts) - # 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 + + # Aliases should have their own section + self.assertIn('Action aliases', data['modes'][''], + 'Aliases should have a dedicated section') + alias_section = data['modes']['']['Action aliases'] + + # Bound alias should appear in the alias section with its key + bound = [b for b in alias_section if b.get('alias') == 'launch_tab'] + self.ae(len(bound), 1, 'Bound alias should appear exactly once') + self.ae(bound[0]['action'], 'launch') + self.assertTrue(bound[0]['key'] != '', 'Bound alias should have its key') + + # Unbound alias should also appear in the alias section + unbound = [b for b in alias_section if b['action_display'] == 'launch_bg'] + self.ae(len(unbound), 1, 'Unbound alias should appear exactly once') + self.ae(unbound[0]['action'], 'launch') + self.ae(unbound[0]['key'], '') + self.assertTrue(unbound[0]['help'].startswith('Alias for:')) + + # Bound alias should NOT appear in any other category + for cat_name, bindings in data['modes'][''].items(): + if cat_name == 'Action aliases': + continue 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' - ) + self.assertNotEqual(b.get('alias'), 'launch_tab', + f'Alias binding should not appear in {cat_name!r}') + + def test_kitten_alias_section(self): + from kittens.command_palette.main import collect_keys_data + from kitty.options.utils import ActionAlias, AliasMap + opts = self.set_options() + # Set up a kitten alias: kitten hints -> kitten hints --hints-offset=0 + alias_map = AliasMap() + alias_map.append('kitten', ActionAlias('hints', 'hints --hints-offset=0', replace_second_arg=True)) + opts.alias_map = alias_map + + data = collect_keys_data(opts) + self.assertIn('Kitten aliases', data['modes'][''], + 'Kitten aliases should have a dedicated section') + kitten_section = data['modes']['']['Kitten aliases'] + found = [b for b in kitten_section if b['action_display'] == 'kitten hints'] + self.ae(len(found), 1, 'Kitten alias should appear exactly once') + self.ae(found[0]['definition'], 'kitten hints --hints-offset=0') + self.assertTrue(found[0]['help'].startswith('Alias for:')) + + def test_combine_actions_section(self): + from kittens.command_palette.main import collect_keys_data + from kitty.options.utils import parse_map + opts = self.set_options() + # Add a combine keybinding + for kd in parse_map('f2 combine : new_tab : launch vim'): + kd = kd.resolve_and_copy(opts.kitty_mod) + default_mode = opts.keyboard_modes[''] + default_mode.keymap.setdefault(kd.trigger, []).append(kd) + + data = collect_keys_data(opts) + # Combine bindings should have their own section + self.assertIn('Combined actions', data['modes'][''], + 'Combine bindings should have a dedicated section') + combine_section = data['modes']['']['Combined actions'] + found = [b for b in combine_section if b['action'] == 'combine'] + self.assertTrue(len(found) > 0, 'Combine binding should be in the section') + self.ae(found[0]['key'], 'f2') + + def test_no_duplicate_alias_entries(self): + from kittens.command_palette.main import collect_keys_data + from kitty.options.utils import ActionAlias, AliasMap, parse_map + opts = self.set_options() + # Set up aliases, some bound to keys and some not + alias_map = AliasMap() + alias_map.append('launch_tab', ActionAlias('launch_tab', 'launch --type=tab --cwd=current')) + alias_map.append('launch_bg', ActionAlias('launch_bg', 'launch --type=background')) + opts.alias_map = alias_map + for kd in parse_map('f1 launch_tab vim'): + kd = kd.resolve_and_copy(opts.kitty_mod) + default_mode = opts.keyboard_modes[''] + default_mode.keymap.setdefault(kd.trigger, []).append(kd) + + data = collect_keys_data(opts) + # Collect all entries that have an alias field across all categories + alias_entries: list[tuple[str, str]] = [] + for cat_name, bindings in data['modes'][''].items(): + for b in bindings: + if 'alias' in b: + alias_entries.append((cat_name, b['alias'])) + # Each alias should appear exactly once + seen: set[str] = set() + for cat_name, alias_name in alias_entries: + self.assertNotIn(alias_name, seen, + f'Alias {alias_name!r} appears in multiple places') + seen.add(alias_name) + + def test_unmapped_actions_sorted_order(self): + # Covered by test_collect_keys_bindings_sorted + pass