Cleanup previous PR

Various fixes and improvements to the command palette kitten

Fixes #9585
This commit is contained in:
copilot-swe-agent[bot]
2026-03-03 04:35:24 +00:00
committed by Kovid Goyal
parent 813f8ba7cf
commit 347c829156
7 changed files with 369 additions and 38 deletions

View File

@@ -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 {

View File

@@ -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 <name> 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]] = {}

View File

@@ -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)
}
}

View File

@@ -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',

View File

@@ -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.

View File

@@ -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

View File

@@ -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