From 33b5b0a339cd8e462777a573153b13fe475efcbc Mon Sep 17 00:00:00 2001 From: Daniel M German Date: Sun, 22 Feb 2026 09:34:19 -0800 Subject: [PATCH 1/3] Add command_palette command to display bound keys Add a Go-based command_palette kitten that provides a searchable, interactive overlay for browsing and triggering keyboard shortcuts and actions. - New Go kitten at kittens/command_palette/ with FZF fuzzy search, grouped/flat views, and remote control action triggering - Python collect_keys_data() does data collection, passed via stdin - Navigation: arrows, ctrl+j/k (vim), ctrl+n/p (emacs), page up/down - Enter triggers the selected action via RC command - Help text displayed in footer for selected binding - Added Go tests (main_test.go) and Python tests (command_palette.py) --- kittens/command_palette/__init__.py | 0 kittens/command_palette/main.go | 614 +++++++++++++++++++++++++++ kittens/command_palette/main.py | 122 ++++++ kittens/command_palette/main_test.go | 309 ++++++++++++++ kitty/boss.py | 6 + kitty_tests/command_palette.py | 99 +++++ shell-integration/ssh/kitty | 2 +- tools/cmd/tool/main.go | 3 + 8 files changed, 1154 insertions(+), 1 deletion(-) create mode 100644 kittens/command_palette/__init__.py create mode 100644 kittens/command_palette/main.go create mode 100644 kittens/command_palette/main.py create mode 100644 kittens/command_palette/main_test.go create mode 100644 kitty_tests/command_palette.py diff --git a/kittens/command_palette/__init__.py b/kittens/command_palette/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go new file mode 100644 index 000000000..c9b1ac765 --- /dev/null +++ b/kittens/command_palette/main.go @@ -0,0 +1,614 @@ +// License: GPLv3 Copyright: 2024, Kovid Goyal + +package command_palette + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/kovidgoyal/kitty/tools/cli" + "github.com/kovidgoyal/kitty/tools/fzf" + "github.com/kovidgoyal/kitty/tools/tui/loop" + "github.com/kovidgoyal/kitty/tools/utils" + "github.com/kovidgoyal/kitty/tools/wcswidth" +) + +var _ = fmt.Print + +// JSON data structures matching Python collect_keys_data output +type Binding struct { + Key string `json:"key"` + Action string `json:"action"` + ActionDisplay string `json:"action_display"` + Help string `json:"help"` + LongHelp string `json:"long_help"` + Category string + Mode string + IsMouse bool +} + +type InputData struct { + Modes map[string]map[string][]Binding `json:"modes"` + Mouse []Binding `json:"mouse"` + ModeOrder []string `json:"mode_order"` + CategoryOrder map[string][]string `json:"category_order"` +} + +// DisplayItem wraps a binding with its search text for FZF scoring +type DisplayItem struct { + binding Binding + searchText string // key + action_display + category for FZF +} + +type displayLine struct { + text string + isHeader bool + isModeHdr bool + itemIdx int // index into filtered_idx, -1 for headers +} + +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 + query string + selected_idx int + scroll_offset int + input_data InputData +} + +func (h *Handler) initialize() (string, error) { + sz, err := h.lp.ScreenSize() + if err != nil { + return "", err + } + h.screen_size = sz + h.lp.SetCursorVisible(true) + h.lp.SetCursorShape(loop.BAR_CURSOR, true) + h.lp.AllowLineWrapping(false) + h.lp.SetWindowTitle("Command Palette") + + if err := h.loadData(); err != nil { + return "", err + } + + h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) + h.updateFilter() + h.draw_screen() + h.lp.SendOverlayReady() + return "", nil +} + +func (h *Handler) loadData() error { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read stdin: %w", err) + } + if len(data) == 0 { + return fmt.Errorf("no input data received on stdin; this kitten must be launched from kitty") + } + if err := json.Unmarshal(data, &h.input_data); err != nil { + return fmt.Errorf("failed to parse input data: %w", err) + } + + h.flattenBindings() + return nil +} + +// flattenBindings converts the hierarchical mode/category/binding data into +// a flat list suitable for display and FZF scoring. Uses the explicit ordering +// arrays from Python since Go maps do not preserve insertion order. +func (h *Handler) flattenBindings() { + // Use explicit mode ordering from Python, falling back to sorted keys + modeNames := h.input_data.ModeOrder + if len(modeNames) == 0 { + modeNames = make([]string, 0, len(h.input_data.Modes)) + for name := range h.input_data.Modes { + modeNames = append(modeNames, name) + } + sort.Slice(modeNames, func(i, j int) bool { + if modeNames[i] == "" { + return true + } + if modeNames[j] == "" { + return false + } + return modeNames[i] < modeNames[j] + }) + } + + for _, modeName := range modeNames { + categories, ok := h.input_data.Modes[modeName] + if !ok { + continue + } + + // Use explicit category ordering from Python, falling back to sorted keys + catNames := h.input_data.CategoryOrder[modeName] + if len(catNames) == 0 { + catNames = make([]string, 0, len(categories)) + for name := range categories { + catNames = append(catNames, name) + } + sort.Strings(catNames) + } + + for _, catName := range catNames { + bindings, ok := categories[catName] + if !ok { + continue + } + for _, b := range bindings { + b.Category = catName + b.Mode = modeName + b.IsMouse = false + searchText := b.Key + " " + b.ActionDisplay + " " + catName + if modeName != "" { + searchText += " " + modeName + } + h.all_items = append(h.all_items, DisplayItem{ + binding: b, + searchText: searchText, + }) + } + } + } + + // Mouse bindings + for _, b := range h.input_data.Mouse { + 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, + }) + } + + // 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() { + if h.query == "" { + // Show all items in original order + h.filtered_idx = make([]int, len(h.all_items)) + 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 + } + 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}) + } + } + 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 + } + } + h.selected_idx = 0 + h.scroll_offset = 0 +} + +func (h *Handler) selectedBinding() *Binding { + if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) { + return nil + } + idx := h.filtered_idx[h.selected_idx] + if idx < 0 || idx >= len(h.all_items) { + return nil + } + return &h.all_items[idx].binding +} + +func (h *Handler) draw_screen() { + h.lp.StartAtomicUpdate() + defer h.lp.EndAtomicUpdate() + h.lp.ClearScreen() + + width := int(h.screen_size.WidthCells) + height := int(h.screen_size.HeightCells) + if width < 10 || height < 5 { + return + } + + // Layout: line 1 = search bar, lines 2..height-2 = results, + // line height-1 = help text, line height = key hints + searchBarY := 1 + resultsStartY := 2 + helpY := height - 1 + hintsY := height + resultsHeight := helpY - resultsStartY + if resultsHeight < 1 { + resultsHeight = 1 + } + + // Draw search bar + h.lp.MoveCursorTo(1, searchBarY) + h.lp.QueueWriteString(h.lp.SprintStyled("fg=bright-yellow", "> ")) + h.lp.QueueWriteString(h.query) + + // Draw results + if h.query == "" { + h.drawGroupedResults(resultsStartY, resultsHeight, width) + } else { + h.drawFlatResults(resultsStartY, resultsHeight, width) + } + + // Draw help text for selected binding + h.lp.MoveCursorTo(1, helpY) + if b := h.selectedBinding(); b != nil && b.Help != "" { + helpStr := b.Help + maxLen := width - 2 + if maxLen < 3 { + maxLen = 3 + } + if wcswidth.Stringwidth(helpStr) > maxLen { + // Truncate by runes to avoid breaking multi-byte characters + runes := []rune(helpStr) + for len(runes) > 0 && wcswidth.Stringwidth(string(runes))+3 > maxLen { + runes = runes[:len(runes)-1] + } + helpStr = string(runes) + "..." + } + h.lp.QueueWriteString(h.lp.SprintStyled("dim italic", " "+helpStr)) + } + + // Draw key hints footer + h.lp.MoveCursorTo(1, hintsY) + footer := h.lp.SprintStyled("fg=bright-yellow", "[Enter]") + " Run " + + h.lp.SprintStyled("fg=bright-yellow", "[Esc]") + " Quit " + + h.lp.SprintStyled("fg=bright-yellow", "\u2191\u2193") + " Navigate" + matchInfo := "" + if h.query != "" { + matchInfo = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items)) + } + h.lp.QueueWriteString(" " + footer + h.lp.SprintStyled("dim", matchInfo)) + + // Position cursor at end of search text for typing + h.lp.MoveCursorTo(3+wcswidth.Stringwidth(h.query), searchBarY) +} + +func (h *Handler) drawGroupedResults(startY, maxRows, width int) { + var lines []displayLine + lastMode := "" + lastCategory := "" + + for fi, idx := range h.filtered_idx { + item := &h.all_items[idx] + b := &item.binding + + // Mode header when mode changes + if b.Mode != lastMode { + lastMode = b.Mode + lastCategory = "" + if b.Mode != "" { + if len(lines) > 0 { + lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) + } + lines = append(lines, displayLine{ + text: fmt.Sprintf(" Mode: %s", b.Mode), + isModeHdr: true, isHeader: true, itemIdx: -1, + }) + lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) + } + } + + // Category header when category changes + if b.Category != lastCategory { + lastCategory = b.Category + if len(lines) > 0 && !lines[len(lines)-1].isHeader { + lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) + } + catWidth := wcswidth.Stringwidth(b.Category) + sepLen := max(0, width-catWidth-6) + sep := strings.Repeat("\u2500", sepLen) + lines = append(lines, displayLine{ + text: fmt.Sprintf(" \u2500\u2500 %s %s", b.Category, sep), + isHeader: true, itemIdx: -1, + }) + } + + // Binding line + keyWidth := wcswidth.Stringwidth(b.Key) + keyPad := 30 + if keyWidth > keyPad-4 { + keyPad = keyWidth + 6 + } + lines = append(lines, displayLine{ + text: fmt.Sprintf(" %-*s %s", keyPad, b.Key, b.ActionDisplay), + itemIdx: fi, + }) + } + + h.drawLines(lines, startY, maxRows, width) +} + +func (h *Handler) drawFlatResults(startY, maxRows, width int) { + var lines []displayLine + for fi, idx := range h.filtered_idx { + item := &h.all_items[idx] + b := &item.binding + keyWidth := wcswidth.Stringwidth(b.Key) + keyPad := 30 + if keyWidth > keyPad-4 { + keyPad = keyWidth + 6 + } + catSuffix := "" + if b.Mode != "" { + catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category) + } else { + catSuffix = fmt.Sprintf(" [%s]", b.Category) + } + lines = append(lines, displayLine{ + text: fmt.Sprintf(" %-*s %-30s%s", keyPad, b.Key, b.ActionDisplay, catSuffix), + itemIdx: fi, + }) + } + + h.drawLines(lines, startY, maxRows, width) +} + +func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) { + if maxRows <= 0 || len(lines) == 0 { + return + } + + // Adjust scroll to keep selected item visible + selectedLineIdx := -1 + for i, dl := range lines { + if dl.itemIdx == h.selected_idx { + selectedLineIdx = i + break + } + } + if selectedLineIdx >= 0 { + if selectedLineIdx < h.scroll_offset { + h.scroll_offset = selectedLineIdx + } + if selectedLineIdx >= h.scroll_offset+maxRows { + h.scroll_offset = selectedLineIdx - maxRows + 1 + } + } + h.scroll_offset = max(0, h.scroll_offset) + h.scroll_offset = min(h.scroll_offset, max(0, len(lines)-maxRows)) + + end := min(h.scroll_offset+maxRows, len(lines)) + for row, li := range lines[h.scroll_offset:end] { + h.lp.MoveCursorTo(1, startY+row) + text := li.text + // Truncate at rune boundary to avoid breaking multi-byte characters + if wcswidth.Stringwidth(text) > width { + runes := []rune(text) + for len(runes) > 0 && wcswidth.Stringwidth(string(runes)) > width { + runes = runes[:len(runes)-1] + } + text = string(runes) + } + + if li.isModeHdr { + h.lp.QueueWriteString(h.lp.SprintStyled("bold fg=magenta", text)) + } else if li.isHeader { + h.lp.QueueWriteString(h.lp.SprintStyled("fg=bright-blue", text)) + } else if li.itemIdx == h.selected_idx { + // Selected item: highlight with reverse video + padded := text + textWidth := wcswidth.Stringwidth(text) + if textWidth < width { + padded += strings.Repeat(" ", width-textWidth) + } + h.lp.QueueWriteString(h.lp.SprintStyled("fg=black bg=white", padded)) + } else { + h.drawBindingLine(text, li.itemIdx, width) + } + } +} + +func (h *Handler) drawBindingLine(text string, filteredIdx, width int) { + if filteredIdx < 0 || filteredIdx >= len(h.filtered_idx) { + h.lp.QueueWriteString(text) + return + } + idx := h.filtered_idx[filteredIdx] + if idx < 0 || idx >= len(h.all_items) { + h.lp.QueueWriteString(text) + return + } + b := &h.all_items[idx].binding + + // Style the key portion green, leave action unstyled + keyWidth := wcswidth.Stringwidth(b.Key) + keyPad := 30 + if keyWidth > keyPad-4 { + keyPad = keyWidth + 6 + } + keyPrefix := fmt.Sprintf(" %-*s", keyPad, b.Key) + rest := "" + if len(text) > len(keyPrefix) { + rest = text[len(keyPrefix):] + } + h.lp.QueueWriteString(h.lp.SprintStyled("fg=green", keyPrefix)) + h.lp.QueueWriteString(rest) +} + +func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error { + if ev.MatchesPressOrRepeat("escape") { + ev.Handled = true + if h.query != "" { + h.query = "" + h.updateFilter() + h.draw_screen() + } else { + h.lp.Quit(0) + } + return nil + } + if ev.MatchesPressOrRepeat("enter") { + ev.Handled = true + h.triggerSelected() + return nil + } + if ev.MatchesPressOrRepeat("up") || ev.MatchesPressOrRepeat("ctrl+k") || ev.MatchesPressOrRepeat("ctrl+p") { + ev.Handled = true + h.moveSelection(-1) + return nil + } + if ev.MatchesPressOrRepeat("down") || ev.MatchesPressOrRepeat("ctrl+j") || ev.MatchesPressOrRepeat("ctrl+n") { + ev.Handled = true + h.moveSelection(1) + return nil + } + if ev.MatchesPressOrRepeat("page_up") { + ev.Handled = true + delta := max(1, int(h.screen_size.HeightCells)-4) + h.moveSelection(-delta) + return nil + } + if ev.MatchesPressOrRepeat("page_down") { + ev.Handled = true + delta := max(1, int(h.screen_size.HeightCells)-4) + h.moveSelection(delta) + return nil + } + if ev.MatchesPressOrRepeat("home") || ev.MatchesPressOrRepeat("ctrl+home") { + ev.Handled = true + h.selected_idx = 0 + h.draw_screen() + return nil + } + if ev.MatchesPressOrRepeat("end") || ev.MatchesPressOrRepeat("ctrl+end") { + ev.Handled = true + if len(h.filtered_idx) > 0 { + h.selected_idx = len(h.filtered_idx) - 1 + } + h.draw_screen() + return nil + } + if ev.MatchesPressOrRepeat("backspace") { + ev.Handled = true + if h.query != "" { + g := wcswidth.SplitIntoGraphemes(h.query) + h.query = strings.Join(g[:len(g)-1], "") + h.updateFilter() + h.draw_screen() + } else { + h.lp.Beep() + } + return nil + } + return nil +} + +func (h *Handler) onText(text string, from_key_event bool, in_bracketed_paste bool) error { + h.query += text + h.updateFilter() + h.draw_screen() + return nil +} + +func (h *Handler) onResize(old, new_size loop.ScreenSize) error { + h.screen_size = new_size + h.draw_screen() + return nil +} + +func (h *Handler) moveSelection(delta int) { + if len(h.filtered_idx) == 0 { + return + } + h.selected_idx += delta + h.selected_idx = max(0, h.selected_idx) + h.selected_idx = min(h.selected_idx, len(h.filtered_idx)-1) + h.draw_screen() +} + +func (h *Handler) triggerSelected() { + b := h.selectedBinding() + if b == nil || b.IsMouse { + h.lp.Beep() + return + } + + // Send RC action command via DCS escape code. + // Do not set "self" or "match_window" — the action runs globally via + // the boss, same as if the user had pressed the keyboard shortcut. + payload := map[string]any{ + "action": b.ActionDisplay, + } + + rc := utils.RemoteControlCmd{ + Cmd: "action", + Version: [3]int{0, 26, 0}, + NoResponse: true, + } + rc.Payload = payload + + data, err := json.Marshal(rc) + if err != nil { + h.lp.Beep() + return + } + h.lp.QueueWriteString("\x1bP@kitty-cmd") + h.lp.QueueWriteString(string(data)) + h.lp.QueueWriteString("\x1b\\") + h.lp.Quit(0) +} + +func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { + lp, err := loop.New() + if err != nil { + return 1, err + } + + handler := &Handler{lp: lp} + + lp.OnInitialize = func() (string, error) { + return handler.initialize() + } + lp.OnFinalize = func() string { return "" } + lp.OnKeyEvent = handler.onKeyEvent + lp.OnText = handler.onText + lp.OnResize = handler.onResize + + err = lp.Run() + if err != nil { + return 1, err + } + ds := lp.DeathSignalName() + if ds != "" { + fmt.Println("Killed by signal:", ds) + lp.KillIfSignalled() + return + } + rc = lp.ExitCode() + return +} + +func EntryPoint(parent *cli.Command) { + create_cmd(parent, main) +} diff --git a/kittens/command_palette/main.py b/kittens/command_palette/main.py new file mode 100644 index 000000000..2abb3e95c --- /dev/null +++ b/kittens/command_palette/main.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2024, Kovid Goyal + + +import sys +from typing import Any + +from kitty.typing_compat import BossType + +from ..tui.handler import result_handler + + +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 + + # 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] = {} + for group_key, actions in get_all_actions().items(): + for action in actions: + 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 + + modes: dict[str, dict[str, list[dict[str, str]]]] = {} + + def as_sc(k: 'Any', v: KeyDefinition) -> Shortcut: + if v.is_sequence: + return Shortcut((v.trigger,) + v.rest) + return Shortcut((k,)) + + 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: + 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, + 'help': help_text, + 'long_help': long_help, + }) + # Sort within categories + 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 + + # 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 + 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 { + 'modes': modes, + 'mouse': mouse, + 'mode_order': mode_order, + 'category_order': category_order, + } + + +def main(args: list[str]) -> None: + raise SystemExit('This must be run as kitten command-palette') + + +main.allow_remote_control = True # type: ignore[attr-defined] +main.remote_control_password = True # type: ignore[attr-defined] + + +@result_handler(has_ready_notification=True) +def handle_result(args: list[str], data: dict[str, Any], target_window_id: int, boss: BossType) -> None: + pass + + +help_text = 'Browse and trigger keyboard shortcuts and actions' +usage = '' +OPTIONS = ''.format + + +if __name__ == '__main__': + main(sys.argv) +elif __name__ == '__doc__': + cd = sys.cli_docs # type: ignore + cd['usage'] = usage + cd['options'] = OPTIONS + cd['help_text'] = help_text + cd['short_desc'] = help_text diff --git a/kittens/command_palette/main_test.go b/kittens/command_palette/main_test.go new file mode 100644 index 000000000..e1bb8ffd1 --- /dev/null +++ b/kittens/command_palette/main_test.go @@ -0,0 +1,309 @@ +package command_palette + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/kovidgoyal/kitty/tools/fzf" +) + +func sampleInputJSON() string { + return `{ + "modes": { + "": { + "Copy/paste": [ + {"key": "ctrl+shift+c", "action": "copy_to_clipboard", "action_display": "copy_to_clipboard", "help": "Copy the selected text from the active window to the clipboard", "long_help": ""}, + {"key": "ctrl+shift+v", "action": "paste_from_clipboard", "action_display": "paste_from_clipboard", "help": "Paste from the clipboard to the active window", "long_help": ""} + ], + "Scrolling": [ + {"key": "ctrl+shift+up", "action": "scroll_line_up", "action_display": "scroll_line_up", "help": "Scroll up one line", "long_help": ""}, + {"key": "ctrl+shift+down", "action": "scroll_line_down", "action_display": "scroll_line_down", "help": "Scroll down one line", "long_help": ""} + ], + "Window management": [ + {"key": "ctrl+shift+enter", "action": "new_window", "action_display": "new_window", "help": "Open a new window", "long_help": ""} + ] + }, + "mw": { + "Miscellaneous": [ + {"key": "left", "action": "neighboring_window", "action_display": "neighboring_window left", "help": "Focus neighbor window", "long_help": ""}, + {"key": "esc", "action": "pop_keyboard_mode", "action_display": "pop_keyboard_mode", "help": "Pop keyboard mode", "long_help": ""} + ] + } + }, + "mouse": [ + {"key": "left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection normal", "help": "", "long_help": ""}, + {"key": "ctrl+left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection rectangle", "help": "", "long_help": ""} + ], + "mode_order": ["", "mw"], + "category_order": { + "": ["Copy/paste", "Scrolling", "Window management"], + "mw": ["Miscellaneous"] + } + }` +} + +func newTestHandler() *Handler { + h := &Handler{} + if err := json.Unmarshal([]byte(sampleInputJSON()), &h.input_data); err != nil { + panic("test data JSON is invalid: " + err.Error()) + } + h.flattenBindings() + h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) + return h +} + +func TestFlattenAllBindings(t *testing.T) { + h := newTestHandler() + // 5 default mode + 2 mw mode + 2 mouse = 9 + if len(h.all_items) != 9 { + t.Fatalf("Expected 9 items, got %d", len(h.all_items)) + } +} + +func TestDefaultModeComesFirst(t *testing.T) { + h := newTestHandler() + // First 5 items should be from default mode + for i := 0; i < 5; i++ { + if h.all_items[i].binding.Mode != "" { + t.Fatalf("Item %d should be from default mode, got mode=%q", i, h.all_items[i].binding.Mode) + } + } +} + +func TestCategoryOrderPreserved(t *testing.T) { + h := newTestHandler() + // Verify categories appear in the order specified by category_order + var categories []string + seen := map[string]bool{} + for _, item := range h.all_items { + if item.binding.Mode != "" || item.binding.IsMouse { + continue + } + cat := item.binding.Category + if !seen[cat] { + categories = append(categories, cat) + seen[cat] = true + } + } + expected := []string{"Copy/paste", "Scrolling", "Window management"} + if len(categories) != len(expected) { + t.Fatalf("Expected %d categories, got %d: %v", len(expected), len(categories), categories) + } + for i, cat := range categories { + if cat != expected[i] { + t.Fatalf("Category %d: expected %q, got %q", i, expected[i], cat) + } + } +} + +func TestCustomModePresent(t *testing.T) { + h := newTestHandler() + found := false + for _, item := range h.all_items { + if item.binding.Mode == "mw" { + found = true + break + } + } + if !found { + t.Fatal("Expected to find items from 'mw' mode") + } +} + +func TestMouseBindingsMarkedCorrectly(t *testing.T) { + h := newTestHandler() + mouseCount := 0 + for _, item := range h.all_items { + if item.binding.IsMouse { + mouseCount++ + if item.binding.Category != "Mouse actions" { + t.Fatalf("Mouse binding should have category 'Mouse actions', got %q", item.binding.Category) + } + } + } + if mouseCount != 2 { + t.Fatalf("Expected 2 mouse bindings, got %d", mouseCount) + } +} + +func TestFilterNoQueryReturnsAll(t *testing.T) { + h := newTestHandler() + h.query = "" + h.updateFilter() + if len(h.filtered_idx) != len(h.all_items) { + t.Fatalf("With no query, expected %d items, got %d", len(h.all_items), len(h.filtered_idx)) + } + for i, idx := range h.filtered_idx { + if idx != i { + t.Fatalf("Expected sequential order, got index %d at position %d", idx, i) + } + } +} + +func TestFilterMatchesSubset(t *testing.T) { + h := newTestHandler() + h.query = "clipboard" + h.updateFilter() + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for 'clipboard'") + } + 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 + 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 + } + } +} + +func TestFilterNonsenseReturnsEmpty(t *testing.T) { + h := newTestHandler() + h.query = "zzzznonexistent" + h.updateFilter() + if len(h.filtered_idx) != 0 { + t.Fatalf("Expected no matches for nonsense, got %d", len(h.filtered_idx)) + } +} + +func TestFilterResetsSelectionAndScroll(t *testing.T) { + h := newTestHandler() + h.query = "" + h.updateFilter() + h.selected_idx = 3 + h.scroll_offset = 5 + + h.query = "scroll" + h.updateFilter() + if h.selected_idx != 0 { + t.Fatalf("Expected selection reset to 0, got %d", h.selected_idx) + } + if h.scroll_offset != 0 { + t.Fatalf("Expected scroll offset reset to 0, got %d", h.scroll_offset) + } +} + +func TestSelectedBindingValid(t *testing.T) { + h := newTestHandler() + h.updateFilter() + + b := h.selectedBinding() + if b == nil { + t.Fatal("Expected non-nil binding") + } + if b.Key == "" || b.Action == "" { + t.Fatal("Binding should have non-empty key and action") + } +} + +func TestSelectedBindingNilWhenEmpty(t *testing.T) { + h := newTestHandler() + h.query = "zzzznonexistent" + h.updateFilter() + + if b := h.selectedBinding(); b != nil { + t.Fatal("Expected nil binding when no matches") + } +} + +func TestSelectedBindingNilWhenNegativeIndex(t *testing.T) { + h := newTestHandler() + h.updateFilter() + h.selected_idx = -1 + + if b := h.selectedBinding(); b != nil { + t.Fatal("Expected nil binding for negative index") + } +} + +func TestSelectedBindingNilWhenOverflowIndex(t *testing.T) { + h := newTestHandler() + h.updateFilter() + h.selected_idx = len(h.filtered_idx) + 10 + + if b := h.selectedBinding(); b != nil { + t.Fatal("Expected nil binding for overflow index") + } +} + +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) + } + 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) + } + } +} + +func TestHelpTextPreserved(t *testing.T) { + h := newTestHandler() + helpCount := 0 + for _, item := range h.all_items { + if item.binding.Help != "" { + helpCount++ + } + } + if helpCount == 0 { + t.Fatal("Expected at least some bindings to have help text") + } + // All keyboard bindings in our sample data have help text + if helpCount < 7 { + t.Fatalf("Expected at least 7 bindings with help text, got %d", helpCount) + } +} + +func TestEmptyInputData(t *testing.T) { + h := &Handler{} + emptyJSON := `{"modes": {}, "mouse": [], "mode_order": [], "category_order": {}}` + if err := json.Unmarshal([]byte(emptyJSON), &h.input_data); err != nil { + t.Fatal(err) + } + h.flattenBindings() + h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) + h.updateFilter() + + if len(h.all_items) != 0 { + t.Fatalf("Expected 0 items for empty data, got %d", len(h.all_items)) + } + if len(h.filtered_idx) != 0 { + t.Fatalf("Expected 0 filtered items, got %d", len(h.filtered_idx)) + } + if b := h.selectedBinding(); b != nil { + t.Fatal("Expected nil binding for empty data") + } +} + +func TestFallbackOrderingWithoutExplicitOrder(t *testing.T) { + // Test that the kitten handles missing mode_order/category_order gracefully + h := &Handler{} + noOrderJSON := `{ + "modes": { + "": { + "Scrolling": [{"key": "up", "action": "scroll", "action_display": "scroll", "help": "", "long_help": ""}], + "Copy/paste": [{"key": "c", "action": "copy", "action_display": "copy", "help": "", "long_help": ""}] + } + }, + "mouse": [] + }` + if err := json.Unmarshal([]byte(noOrderJSON), &h.input_data); err != nil { + t.Fatal(err) + } + h.flattenBindings() + + if len(h.all_items) != 2 { + t.Fatalf("Expected 2 items, got %d", len(h.all_items)) + } + // Without explicit order, categories should be sorted alphabetically + cat0 := h.all_items[0].binding.Category + cat1 := h.all_items[1].binding.Category + if cat0 > cat1 { + t.Fatalf("Expected alphabetical category order, got %q then %q", cat0, cat1) + } +} diff --git a/kitty/boss.py b/kitty/boss.py index 6d6832194..71c681bee 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2294,6 +2294,12 @@ class Boss: 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: + from kittens.command_palette.main import collect_keys_data + data = collect_keys_data(get_options()) + self.run_kitten_with_metadata('command-palette', input_data=json.dumps(data), window=self.window_for_dispatch) + @ac( 'tab', ''' Change the title of the active tab interactively, by typing in the new title. diff --git a/kitty_tests/command_palette.py b/kitty_tests/command_palette.py new file mode 100644 index 000000000..30fb71842 --- /dev/null +++ b/kitty_tests/command_palette.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2024, Kovid Goyal + +from . import BaseTest + + +class TestCommandPalette(BaseTest): + + def test_collect_keys_data(self): + from kittens.command_palette.main import collect_keys_data + from kitty.actions import groups + opts = self.set_options() + data = collect_keys_data(opts) + self.assertIn('modes', data) + self.assertIn('mouse', data) + self.assertIn('', data['modes'], 'Default keyboard mode should be present') + 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()) + 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 + for cat_name, bindings in default_mode.items(): + self.assertIsInstance(bindings, list) + for b in bindings: + self.assertIn('key', b) + self.assertIn('action', b) + self.assertIn('action_display', b) + self.assertIn('help', b) + self.assertIn('long_help', b) + self.assertIsInstance(b['key'], str) + self.assertIsInstance(b['action'], str) + self.assertTrue(len(b['key']) > 0) + self.assertTrue(len(b['action']) > 0) + # Mouse mappings + self.assertIsInstance(data['mouse'], list) + for b in data['mouse']: + self.assertIn('key', b) + self.assertIn('action', b) + self.assertIn('action_display', b) + + def test_collect_keys_categories_ordered(self): + from kittens.command_palette.main import collect_keys_data + from kitty.actions import groups + opts = self.set_options() + data = collect_keys_data(opts) + default_mode = data['modes'][''] + cat_names = list(default_mode.keys()) + group_titles = list(groups.values()) + # Categories should appear in the same order as defined in groups + indices = [] + for cat in cat_names: + if cat in group_titles: + indices.append(group_titles.index(cat)) + self.ae(indices, sorted(indices), 'Categories should be ordered according to groups dict') + + def test_collect_keys_bindings_sorted(self): + from kittens.command_palette.main import collect_keys_data + opts = self.set_options() + data = collect_keys_data(opts) + for cat_name, bindings in data['modes'][''].items(): + keys = [b['key'] for b in bindings] + self.ae(keys, sorted(keys), f'Bindings in {cat_name} should be sorted by key') + + def test_collect_keys_has_help_text(self): + from kittens.command_palette.main import collect_keys_data + opts = self.set_options() + data = collect_keys_data(opts) + # At least some bindings should have help text + has_help = False + for cat_name, bindings in data['modes'][''].items(): + for b in bindings: + if b['help']: + has_help = True + break + if has_help: + break + self.assertTrue(has_help, 'At least some bindings should have help text') + + def test_ordering_arrays_present(self): + from kittens.command_palette.main import collect_keys_data + opts = self.set_options() + data = collect_keys_data(opts) + # mode_order should list all modes + self.assertIn('mode_order', data) + self.assertIsInstance(data['mode_order'], list) + self.ae(set(data['mode_order']), set(data['modes'].keys())) + # category_order should list categories for each mode + self.assertIn('category_order', data) + self.assertIsInstance(data['category_order'], dict) + for mode_name in data['modes']: + self.assertIn(mode_name, data['category_order']) + self.ae( + set(data['category_order'][mode_name]), + set(data['modes'][mode_name].keys()), + f'category_order for mode {mode_name!r} should match modes keys', + ) diff --git a/shell-integration/ssh/kitty b/shell-integration/ssh/kitty index 08e30e9d2..0d1cda9a5 100755 --- a/shell-integration/ssh/kitty +++ b/shell-integration/ssh/kitty @@ -24,7 +24,7 @@ exec_kitty() { is_wrapped_kitten() { - wrapped_kittens="clipboard icat hyperlinked_grep ask hints unicode_input ssh themes diff show_key transfer query_terminal choose-files" + wrapped_kittens="clipboard icat hyperlinked_grep ask hints unicode_input ssh themes diff show_key transfer query_terminal choose-files command-palette" [ -n "$1" ] && { case " $wrapped_kittens " in *" $1 "*) printf "%s" "$1" ;; diff --git a/tools/cmd/tool/main.go b/tools/cmd/tool/main.go index c2f079460..6950039cc 100644 --- a/tools/cmd/tool/main.go +++ b/tools/cmd/tool/main.go @@ -7,6 +7,7 @@ import ( "github.com/kovidgoyal/kitty/kittens/ask" "github.com/kovidgoyal/kitty/kittens/choose_files" + "github.com/kovidgoyal/kitty/kittens/command_palette" "github.com/kovidgoyal/kitty/kittens/choose_fonts" "github.com/kovidgoyal/kitty/kittens/clipboard" "github.com/kovidgoyal/kitty/kittens/desktop_ui" @@ -97,6 +98,8 @@ func KittyToolEntryPoints(root *cli.Command) { choose_fonts.EntryPoint(root) // choose-files choose_files.EntryPoint(root) + // command-palette + command_palette.EntryPoint(root) // query-terminal query_terminal.EntryPoint(root) // __pytest__ From 6fa122af115d2ee58e59ae7c19e62d7a54fe6237 Mon Sep 17 00:00:00 2001 From: Daniel M German Date: Sun, 22 Feb 2026 21:12:36 -0800 Subject: [PATCH 2/3] Fix action triggering by using kitten output instead of remote control The RC approach sent actions targeting the overlay window itself rather than the underlying window. Switch to the standard kitten output pattern: the Go kitten outputs the selected action definition via KittenOutputSerializer, and handle_result in Python calls boss.combine() with the correct target window after the overlay closes. --- kittens/command_palette/main.go | 37 +++++++++------------------- kittens/command_palette/main.py | 9 +++---- kittens/command_palette/main_test.go | 18 +++++++------- kitty_tests/command_palette.py | 1 + 4 files changed, 25 insertions(+), 40 deletions(-) diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go index c9b1ac765..b154dfea8 100644 --- a/kittens/command_palette/main.go +++ b/kittens/command_palette/main.go @@ -12,18 +12,17 @@ import ( "github.com/kovidgoyal/kitty/tools/cli" "github.com/kovidgoyal/kitty/tools/fzf" + "github.com/kovidgoyal/kitty/tools/tui" "github.com/kovidgoyal/kitty/tools/tui/loop" - "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/wcswidth" ) -var _ = fmt.Print - // JSON data structures matching Python collect_keys_data output type Binding struct { Key string `json:"key"` Action string `json:"action"` ActionDisplay string `json:"action_display"` + Definition string `json:"definition"` Help string `json:"help"` LongHelp string `json:"long_help"` Category string @@ -62,6 +61,7 @@ type Handler struct { selected_idx int scroll_offset int input_data InputData + result string // action definition to execute after exit } func (h *Handler) initialize() (string, error) { @@ -553,33 +553,12 @@ func (h *Handler) triggerSelected() { h.lp.Beep() return } - - // Send RC action command via DCS escape code. - // Do not set "self" or "match_window" — the action runs globally via - // the boss, same as if the user had pressed the keyboard shortcut. - payload := map[string]any{ - "action": b.ActionDisplay, - } - - rc := utils.RemoteControlCmd{ - Cmd: "action", - Version: [3]int{0, 26, 0}, - NoResponse: true, - } - rc.Payload = payload - - data, err := json.Marshal(rc) - if err != nil { - h.lp.Beep() - return - } - h.lp.QueueWriteString("\x1bP@kitty-cmd") - h.lp.QueueWriteString(string(data)) - h.lp.QueueWriteString("\x1b\\") + h.result = b.Definition h.lp.Quit(0) } func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { + output := tui.KittenOutputSerializer() lp, err := loop.New() if err != nil { return 1, err @@ -606,6 +585,12 @@ func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { return } rc = lp.ExitCode() + if handler.result != "" { + s, serr := output(map[string]string{"action": handler.result}) + if serr == nil { + fmt.Println(s) + } + } return } diff --git a/kittens/command_palette/main.py b/kittens/command_palette/main.py index 2abb3e95c..98d830c07 100644 --- a/kittens/command_palette/main.py +++ b/kittens/command_palette/main.py @@ -57,6 +57,7 @@ def collect_keys_data(opts: Any) -> dict[str, Any]: 'key': key_repr, 'action': action_name, 'action_display': action_repr, + 'definition': d.definition or action_name, 'help': help_text, 'long_help': long_help, }) @@ -98,13 +99,11 @@ def main(args: list[str]) -> None: raise SystemExit('This must be run as kitten command-palette') -main.allow_remote_control = True # type: ignore[attr-defined] -main.remote_control_password = True # type: ignore[attr-defined] - - @result_handler(has_ready_notification=True) def handle_result(args: list[str], data: dict[str, Any], target_window_id: int, boss: BossType) -> None: - pass + if data and 'action' in data: + w = boss.window_id_map.get(target_window_id) + boss.combine(data['action'], w) help_text = 'Browse and trigger keyboard shortcuts and actions' diff --git a/kittens/command_palette/main_test.go b/kittens/command_palette/main_test.go index e1bb8ffd1..04d601767 100644 --- a/kittens/command_palette/main_test.go +++ b/kittens/command_palette/main_test.go @@ -13,27 +13,27 @@ func sampleInputJSON() string { "modes": { "": { "Copy/paste": [ - {"key": "ctrl+shift+c", "action": "copy_to_clipboard", "action_display": "copy_to_clipboard", "help": "Copy the selected text from the active window to the clipboard", "long_help": ""}, - {"key": "ctrl+shift+v", "action": "paste_from_clipboard", "action_display": "paste_from_clipboard", "help": "Paste from the clipboard to the active window", "long_help": ""} + {"key": "ctrl+shift+c", "action": "copy_to_clipboard", "action_display": "copy_to_clipboard", "definition": "copy_to_clipboard", "help": "Copy the selected text from the active window to the clipboard", "long_help": ""}, + {"key": "ctrl+shift+v", "action": "paste_from_clipboard", "action_display": "paste_from_clipboard", "definition": "paste_from_clipboard", "help": "Paste from the clipboard to the active window", "long_help": ""} ], "Scrolling": [ - {"key": "ctrl+shift+up", "action": "scroll_line_up", "action_display": "scroll_line_up", "help": "Scroll up one line", "long_help": ""}, - {"key": "ctrl+shift+down", "action": "scroll_line_down", "action_display": "scroll_line_down", "help": "Scroll down one line", "long_help": ""} + {"key": "ctrl+shift+up", "action": "scroll_line_up", "action_display": "scroll_line_up", "definition": "scroll_line_up", "help": "Scroll up one line", "long_help": ""}, + {"key": "ctrl+shift+down", "action": "scroll_line_down", "action_display": "scroll_line_down", "definition": "scroll_line_down", "help": "Scroll down one line", "long_help": ""} ], "Window management": [ - {"key": "ctrl+shift+enter", "action": "new_window", "action_display": "new_window", "help": "Open a new window", "long_help": ""} + {"key": "ctrl+shift+enter", "action": "new_window", "action_display": "new_window", "definition": "new_window", "help": "Open a new window", "long_help": ""} ] }, "mw": { "Miscellaneous": [ - {"key": "left", "action": "neighboring_window", "action_display": "neighboring_window left", "help": "Focus neighbor window", "long_help": ""}, - {"key": "esc", "action": "pop_keyboard_mode", "action_display": "pop_keyboard_mode", "help": "Pop keyboard mode", "long_help": ""} + {"key": "left", "action": "neighboring_window", "action_display": "neighboring_window left", "definition": "neighboring_window left", "help": "Focus neighbor window", "long_help": ""}, + {"key": "esc", "action": "pop_keyboard_mode", "action_display": "pop_keyboard_mode", "definition": "pop_keyboard_mode", "help": "Pop keyboard mode", "long_help": ""} ] } }, "mouse": [ - {"key": "left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection normal", "help": "", "long_help": ""}, - {"key": "ctrl+left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection rectangle", "help": "", "long_help": ""} + {"key": "left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection normal", "definition": "mouse_selection normal", "help": "", "long_help": ""}, + {"key": "ctrl+left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection rectangle", "definition": "mouse_selection rectangle", "help": "", "long_help": ""} ], "mode_order": ["", "mw"], "category_order": { diff --git a/kitty_tests/command_palette.py b/kitty_tests/command_palette.py index 30fb71842..da32c4931 100644 --- a/kitty_tests/command_palette.py +++ b/kitty_tests/command_palette.py @@ -28,6 +28,7 @@ class TestCommandPalette(BaseTest): self.assertIn('key', b) self.assertIn('action', b) self.assertIn('action_display', b) + self.assertIn('definition', b) self.assertIn('help', b) self.assertIn('long_help', b) self.assertIsInstance(b['key'], str) From 723363601fb6a90e1cd9cc79a340d6eaa9aa330f Mon Sep 17 00:00:00 2001 From: Daniel M German Date: Sun, 22 Feb 2026 21:24:00 -0800 Subject: [PATCH 3/3] Fix CI error by changing order of imports --- tools/cmd/tool/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/cmd/tool/main.go b/tools/cmd/tool/main.go index 6950039cc..0efae28f5 100644 --- a/tools/cmd/tool/main.go +++ b/tools/cmd/tool/main.go @@ -7,9 +7,9 @@ import ( "github.com/kovidgoyal/kitty/kittens/ask" "github.com/kovidgoyal/kitty/kittens/choose_files" - "github.com/kovidgoyal/kitty/kittens/command_palette" "github.com/kovidgoyal/kitty/kittens/choose_fonts" "github.com/kovidgoyal/kitty/kittens/clipboard" + "github.com/kovidgoyal/kitty/kittens/command_palette" "github.com/kovidgoyal/kitty/kittens/desktop_ui" "github.com/kovidgoyal/kitty/kittens/diff" "github.com/kovidgoyal/kitty/kittens/hints"