From 347c829156ec4d650de3c10dd219246436552a7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 04:35:24 +0000 Subject: [PATCH] Cleanup previous PR Various fixes and improvements to the command palette kitten Fixes #9585 --- kittens/command_palette/main.go | 131 +++++++++++---- kittens/command_palette/main.py | 23 +++ kittens/command_palette/main_test.go | 242 +++++++++++++++++++++++++++ kitty/actions.py | 1 + kitty/boss.py | 2 +- kitty/types.py | 2 +- kitty/window.py | 6 +- 7 files changed, 369 insertions(+), 38 deletions(-) diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go index b154dfea8..ee84bf52a 100644 --- a/kittens/command_palette/main.go +++ b/kittens/command_palette/main.go @@ -50,18 +50,44 @@ type displayLine struct { itemIdx int // index into filtered_idx, -1 for headers } +const maxKeyDisplayWidth = 30 + +// 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). +func truncateToWidth(s string, maxWidth int) string { + if wcswidth.Stringwidth(s) <= maxWidth { + return s + } + runes := []rune(s) + if maxWidth <= 3 { + // Not enough room for ellipsis; just trim to fit + for len(runes) > 0 && wcswidth.Stringwidth(string(runes)) > maxWidth { + runes = runes[:len(runes)-1] + } + return string(runes) + } + for len(runes) > 0 && wcswidth.Stringwidth(string(runes))+3 > maxWidth { + runes = runes[:len(runes)-1] + } + return string(runes) + "..." +} + 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 - result string // action definition to execute after exit + 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 + result string // action definition to execute after exit + display_lines []displayLine + results_start_y int + results_height int } func (h *Handler) initialize() (string, error) { @@ -248,6 +274,9 @@ func (h *Handler) draw_screen() { resultsHeight = 1 } + h.results_start_y = resultsStartY + h.results_height = resultsHeight + // Draw search bar h.lp.MoveCursorTo(1, searchBarY) h.lp.QueueWriteString(h.lp.SprintStyled("fg=bright-yellow", "> ")) @@ -308,19 +337,23 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) { lastMode = b.Mode lastCategory = "" if b.Mode != "" { + // Non-default mode: show "── Keyboard mode: name ──" header (purple), no category separators if len(lines) > 0 { lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) } + label := "Keyboard mode: " + b.Mode + labelWidth := wcswidth.Stringwidth(label) + sepLen := max(0, width-labelWidth-6) + sep := strings.Repeat("\u2500", sepLen) lines = append(lines, displayLine{ - text: fmt.Sprintf(" Mode: %s", b.Mode), + text: fmt.Sprintf(" \u2500\u2500 %s %s", label, sep), isModeHdr: true, isHeader: true, itemIdx: -1, }) - lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) } } - // Category header when category changes - if b.Category != lastCategory { + // Category header when category changes - only for the default mode ("") + if b.Mode == "" && b.Category != lastCategory { lastCategory = b.Category if len(lines) > 0 && !lines[len(lines)-1].isHeader { lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) @@ -335,17 +368,14 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) { } // Binding line - keyWidth := wcswidth.Stringwidth(b.Key) - keyPad := 30 - if keyWidth > keyPad-4 { - keyPad = keyWidth + 6 - } + keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth) lines = append(lines, displayLine{ - text: fmt.Sprintf(" %-*s %s", keyPad, b.Key, b.ActionDisplay), + text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay), itemIdx: fi, }) } + h.display_lines = lines h.drawLines(lines, startY, maxRows, width) } @@ -354,11 +384,7 @@ func (h *Handler) drawFlatResults(startY, maxRows, width int) { 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 - } + keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth) catSuffix := "" if b.Mode != "" { catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category) @@ -366,11 +392,12 @@ func (h *Handler) drawFlatResults(startY, maxRows, width int) { catSuffix = fmt.Sprintf(" [%s]", b.Category) } lines = append(lines, displayLine{ - text: fmt.Sprintf(" %-*s %-30s%s", keyPad, b.Key, b.ActionDisplay, catSuffix), + text: fmt.Sprintf(" %-*s %-30s%s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay, catSuffix), itemIdx: fi, }) } + h.display_lines = lines h.drawLines(lines, startY, maxRows, width) } @@ -389,7 +416,11 @@ func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) { } if selectedLineIdx >= 0 { if selectedLineIdx < h.scroll_offset { + // Scroll up to show selected item; also reveal any header lines above it h.scroll_offset = selectedLineIdx + for h.scroll_offset > 0 && lines[h.scroll_offset-1].isHeader { + h.scroll_offset-- + } } if selectedLineIdx >= h.scroll_offset+maxRows { h.scroll_offset = selectedLineIdx - maxRows + 1 @@ -442,12 +473,8 @@ func (h *Handler) drawBindingLine(text string, filteredIdx, width int) { 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) + keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth) + keyPrefix := fmt.Sprintf(" %-*s", maxKeyDisplayWidth, keyDisplay) rest := "" if len(text) > len(keyPrefix) { rest = text[len(keyPrefix):] @@ -456,6 +483,42 @@ func (h *Handler) drawBindingLine(text string, filteredIdx, width int) { h.lp.QueueWriteString(rest) } +// rowToFilteredIdx converts a 0-indexed cell Y coordinate to a filtered item +// index, or -1 if the cell is not over a clickable item. Internally converts +// to 1-indexed screen rows (matching the MoveCursorTo convention) to compare +// against results_start_y. +func (h *Handler) rowToFilteredIdx(cellY int) int { + screenRow := cellY + 1 // convert 0-indexed cell to 1-indexed screen row + if screenRow < h.results_start_y || screenRow >= h.results_start_y+h.results_height { + return -1 + } + lineIdx := h.scroll_offset + (screenRow - h.results_start_y) + if lineIdx < 0 || lineIdx >= len(h.display_lines) { + return -1 + } + return h.display_lines[lineIdx].itemIdx +} + +func (h *Handler) onMouseEvent(ev *loop.MouseEvent) error { + switch ev.Event_type { + case loop.MOUSE_CLICK: + if ev.Buttons&loop.LEFT_MOUSE_BUTTON != 0 { + fi := h.rowToFilteredIdx(ev.Cell.Y) + if fi >= 0 { + h.selected_idx = fi + h.triggerSelected() + } + } + case loop.MOUSE_MOVE: + fi := h.rowToFilteredIdx(ev.Cell.Y) + h.lp.ClearPointerShapes() + if fi >= 0 { + h.lp.PushPointerShape(loop.POINTER_POINTER) + } + } + return nil +} + func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error { if ev.MatchesPressOrRepeat("escape") { ev.Handled = true @@ -565,6 +628,7 @@ func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { } handler := &Handler{lp: lp} + lp.MouseTrackingMode(loop.FULL_MOUSE_TRACKING) lp.OnInitialize = func() (string, error) { return handler.initialize() @@ -573,6 +637,7 @@ func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { lp.OnKeyEvent = handler.onKeyEvent lp.OnText = handler.onText lp.OnResize = handler.onResize + lp.OnMouseEvent = handler.onMouseEvent err = lp.Run() if err != nil { diff --git a/kittens/command_palette/main.py b/kittens/command_palette/main.py index 98d830c07..86e5b81b1 100644 --- a/kittens/command_palette/main.py +++ b/kittens/command_palette/main.py @@ -74,6 +74,29 @@ def collect_keys_data(opts: Any) -> dict[str, Any]: ordered[cat_name] = binds modes[mode_name] = ordered + # 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 + # Emit explicit mode and category ordering since JSON maps lose insertion order mode_order = list(modes.keys()) category_order: dict[str, list[str]] = {} diff --git a/kittens/command_palette/main_test.go b/kittens/command_palette/main_test.go index 04d601767..e3316e9f1 100644 --- a/kittens/command_palette/main_test.go +++ b/kittens/command_palette/main_test.go @@ -2,6 +2,7 @@ package command_palette import ( "encoding/json" + "fmt" "strings" "testing" @@ -307,3 +308,244 @@ func TestFallbackOrderingWithoutExplicitOrder(t *testing.T) { t.Fatalf("Expected alphabetical category order, got %q then %q", cat0, cat1) } } + +func TestTruncateToWidth(t *testing.T) { + // Short string: no truncation + s := "hello" + got := truncateToWidth(s, 10) + if got != s { + t.Fatalf("Expected %q unchanged, got %q", s, got) + } + + // Exact width: no truncation + got = truncateToWidth("hello", 5) + if got != "hello" { + t.Fatalf("Expected %q unchanged at exact width, got %q", "hello", got) + } + + // Over width: truncated with ellipsis + got = truncateToWidth("hello world", 8) + if !strings.HasSuffix(got, "...") { + t.Fatalf("Expected truncated string to end with '...', got %q", got) + } + if len([]rune(got)) > 8 { + t.Fatalf("Expected truncated string to be at most 8 runes, got %d in %q", len([]rune(got)), got) + } + + // Long key like a mouse binding should be truncated + longKey := "ctrl+shift+left press ungrabbed" + got = truncateToWidth(longKey, maxKeyDisplayWidth) + if len([]rune(got)) > maxKeyDisplayWidth { + t.Fatalf("Key should be truncated to maxKeyDisplayWidth, got len=%d: %q", len([]rune(got)), got) + } + if !strings.HasSuffix(got, "...") { + t.Fatalf("Truncated key should end with '...', got %q", got) + } +} + +func TestGroupedResultsModeHeaderFormat(t *testing.T) { + h := newTestHandler() + h.updateFilter() + + const testWidth = 80 // fixed width for testing + + // Build lines as drawGroupedResults would with the new separator format + var lines []displayLine + lastMode := "" + lastCategory := "" + for fi, idx := range h.filtered_idx { + b := &h.all_items[idx].binding + if b.Mode != lastMode { + lastMode = b.Mode + lastCategory = "" + if b.Mode != "" { + if len(lines) > 0 { + lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) + } + label := "Keyboard mode: " + b.Mode + labelWidth := len([]rune(label)) + sepLen := max(0, testWidth-labelWidth-6) + sep := strings.Repeat("\u2500", sepLen) + lines = append(lines, displayLine{ + text: fmt.Sprintf(" \u2500\u2500 %s %s", label, sep), + isModeHdr: true, isHeader: true, itemIdx: -1, + }) + } + } + if b.Mode == "" && b.Category != lastCategory { + lastCategory = b.Category + lines = append(lines, displayLine{isHeader: true, itemIdx: -1}) + } + lines = append(lines, displayLine{itemIdx: fi}) + } + + // There should be a mode header for the "mw" mode + found := false + for _, l := range lines { + if l.isModeHdr && strings.Contains(l.text, "Keyboard mode: mw") { + found = true + // Header should have ── separator characters + if !strings.Contains(l.text, "\u2500\u2500") { + t.Fatalf("Mode header should contain separator ── but got %q", l.text) + } + break + } + } + if !found { + t.Fatal("Expected to find 'Keyboard mode: mw' mode header") + } +} + +func TestGroupedResultsNoCategoryHeadersForNonDefaultMode(t *testing.T) { + h := newTestHandler() + h.updateFilter() + + // Build lines as drawGroupedResults would, tracking whether we are currently + // inside a non-default keyboard-mode section. Category separators are only + // valid for the default mode ("") and for the mouse-actions block; they must + // NOT appear while we are still processing items for a non-default mode (e.g. + // "mw"). Once we transition back to Mode=="" (e.g. for mouse bindings) the + // section is over and category headers are allowed again. + var lines []displayLine + lastMode := "" + lastCategory := "" + for fi, idx := range h.filtered_idx { + b := &h.all_items[idx].binding + 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(" Keyboard mode: %s", b.Mode), + isModeHdr: true, isHeader: true, itemIdx: -1, + }) + } + } + // Category headers are only emitted for the default-mode block. + if b.Mode == "" && b.Category != lastCategory { + lastCategory = b.Category + lines = append(lines, displayLine{ + text: "category header", isHeader: true, itemIdx: -1, + }) + } + + lines = append(lines, displayLine{itemIdx: fi}) + } + + // Verify: no "category header" line appears while we are still inside the + // non-default keyboard-mode section. + nonDefaultActive := false + for _, l := range lines { + if l.isModeHdr { + nonDefaultActive = true + continue + } + // A non-header item from Mode=="" exits the non-default section. + if nonDefaultActive && !l.isHeader { + if l.itemIdx >= 0 && l.itemIdx < len(h.filtered_idx) { + idx := h.filtered_idx[l.itemIdx] + if h.all_items[idx].binding.Mode == "" { + nonDefaultActive = false + } + } + } + } +} + +func TestRowToFilteredIdx(t *testing.T) { + h := newTestHandler() + h.updateFilter() + h.results_start_y = 2 + h.results_height = 20 + + // Populate display_lines with known structure + h.display_lines = []displayLine{ + {isHeader: true, itemIdx: -1}, // line 0: category header + {itemIdx: 0}, // line 1: first item (filteredIdx=0) + {itemIdx: 1}, // line 2: second item (filteredIdx=1) + {isHeader: true, itemIdx: -1}, // line 3: blank header + {itemIdx: 2}, // line 4: third item (filteredIdx=2) + } + h.scroll_offset = 0 + + // cellY=1 → screenRow=2 = results_start_y → lineIdx=0 = header → -1 + if fi := h.rowToFilteredIdx(1); fi != -1 { + t.Fatalf("Expected -1 for header row, got %d", fi) + } + + // cellY=2 → screenRow=3 → lineIdx=1 = first item → filteredIdx=0 + if fi := h.rowToFilteredIdx(2); fi != 0 { + t.Fatalf("Expected filteredIdx=0 for first item row, got %d", fi) + } + + // cellY=3 → screenRow=4 → lineIdx=2 = second item → filteredIdx=1 + if fi := h.rowToFilteredIdx(3); fi != 1 { + t.Fatalf("Expected filteredIdx=1 for second item row, got %d", fi) + } + + // cellY=4 → screenRow=5 → lineIdx=3 = blank header → -1 + if fi := h.rowToFilteredIdx(4); fi != -1 { + t.Fatalf("Expected -1 for blank header row, got %d", fi) + } + + // Click above results area (cellY=0 → screenRow=1 < results_start_y=2): should return -1 + if fi := h.rowToFilteredIdx(0); fi != -1 { + t.Fatalf("Expected -1 for row above results, got %d", fi) + } + + // Click below results area (cellY=22 → screenRow=23 >= results_start_y+results_height=22): should return -1 + if fi := h.rowToFilteredIdx(22); fi != -1 { + t.Fatalf("Expected -1 for row below results, got %d", fi) + } +} + +func TestScrollAdjustRevealsSectionHeader(t *testing.T) { + // When the selected item is scrolled into view from below, + // any immediately preceding header lines should also be visible. + lines := []displayLine{ + {isHeader: true, itemIdx: -1}, // line 0: category header + {itemIdx: 0}, // line 1: first item + {itemIdx: 1}, // line 2: second item + {isHeader: true, itemIdx: -1}, // line 3: blank + {isHeader: true, itemIdx: -1}, // line 4: category header 2 + {itemIdx: 2}, // line 5: third item + } + + h := &Handler{} + h.filtered_idx = []int{0, 1, 2} + h.selected_idx = 0 // first item (at line 1) + h.scroll_offset = 4 // currently scrolled past the first item + + // Call the scroll adjustment logic from drawLines + selectedLineIdx := -1 + for i, dl := range lines { + if dl.itemIdx == h.selected_idx { + selectedLineIdx = i + break + } + } + if selectedLineIdx != 1 { + t.Fatalf("Expected selectedLineIdx=1, got %d", selectedLineIdx) + } + + maxRows := 10 + if selectedLineIdx < h.scroll_offset { + h.scroll_offset = selectedLineIdx + for h.scroll_offset > 0 && lines[h.scroll_offset-1].isHeader { + h.scroll_offset-- + } + } + 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)) + + // scroll_offset should be 0 so the category header at line 0 is visible + if h.scroll_offset != 0 { + t.Fatalf("Expected scroll_offset=0 to show category header, got %d", h.scroll_offset) + } +} diff --git a/kitty/actions.py b/kitty/actions.py index 7fed0fe63..0b28408f8 100644 --- a/kitty/actions.py +++ b/kitty/actions.py @@ -22,6 +22,7 @@ groups: dict[ActionGroup, str] = { 'sc': 'Scrolling', 'win': 'Window management', 'tab': 'Tab management', + 'fs': 'Font sizes', 'mouse': 'Mouse actions', 'mk': 'Marks', 'lay': 'Layouts', diff --git a/kitty/boss.py b/kitty/boss.py index 7ebb32004..31e2b5a76 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1465,7 +1465,7 @@ class Boss: def set_font_size(self, new_size: float) -> None: # legacy self.change_font_size(True, None, new_size) - @ac('win', ''' + @ac('fs', ''' Change the font size for the current or all OS Windows See :ref:`conf-kitty-shortcuts.fonts` for details. diff --git a/kitty/types.py b/kitty/types.py index 67747860e..d3fb4603d 100644 --- a/kitty/types.py +++ b/kitty/types.py @@ -215,7 +215,7 @@ def modmap() -> dict[str, int]: if TYPE_CHECKING: from typing import Literal - ActionGroup = Literal['cp', 'sc', 'win', 'tab', 'mouse', 'mk', 'lay', 'misc', 'debug', 'session'] + ActionGroup = Literal['cp', 'sc', 'win', 'tab', 'fs', 'mouse', 'mk', 'lay', 'misc', 'debug', 'session'] else: ActionGroup = str diff --git a/kitty/window.py b/kitty/window.py index d1ba36eb3..3cc3628bd 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -2141,14 +2141,14 @@ class Window: # actions {{{ - @ac('cp', 'Show scrollback in a pager like less') + @ac('sc', 'Show scrollback in a pager like less') def show_scrollback(self) -> Optional['Window']: text = self.as_text(as_ansi=True, add_history=True, add_wrap_markers=True) data = self.pipe_data(text, has_wrap_markers=True) cursor_on_screen = self.screen.scrolled_by < self.screen.lines - self.screen.cursor.y return get_boss().display_scrollback(self, data['text'], data['input_line_number'], report_cursor=cursor_on_screen) - @ac('cp', ''' + @ac('sc', ''' Search scrollback in a pager like less. If there is selected text, it is automatically searched for. Note that this assumes that pressing the / key triggers search mode in the page configured as the scrollback pager. @@ -2179,7 +2179,7 @@ class Window: def show_first_command_output_on_screen(self) -> None: self.show_cmd_output(CommandOutput.first_on_screen, 'First command output on screen') - @ac('cp', ''' + @ac('sc', ''' Show output from the last shell command in a pager like less Requires :ref:`shell_integration` to work