Allow optionally showing unmapped actions in the command palette

Also highlight letters matching the search query.
Fixes #9589
This commit is contained in:
copilot-swe-agent[bot]
2026-03-03 07:48:15 +00:00
committed by Kovid Goyal
parent a7480370a4
commit f13c8cd44d
6 changed files with 424 additions and 63 deletions

View File

@@ -38,10 +38,16 @@ type InputData struct {
CategoryOrder map[string][]string `json:"category_order"`
}
// DisplayItem wraps a binding with its search text for FZF scoring
// DisplayItem wraps a binding with its per-column search texts for FZF scoring
type DisplayItem struct {
binding Binding
searchText string // key + action_display + category for FZF
binding Binding
colTexts [3]string // [0]=key, [1]=action_display, [2]=category
}
// matchInfo stores which column matched and the matched character positions
type matchInfo struct {
colIdx int // which column matched: 0=key, 1=action_display, 2=category
positions []int // rune positions in the matched column text
}
type displayLine struct {
@@ -53,6 +59,9 @@ type displayLine struct {
const maxKeyDisplayWidth = 30
// unmappedLabel is shown in the key column for actions with no keyboard shortcut.
const unmappedLabel = "(unmapped)"
// truncateToWidth truncates s to fit within maxWidth cells, appending "..." if
// truncated and maxWidth > 3. When maxWidth <= 3, the string is simply trimmed
// to fit without appending ellipsis (no room for it).
@@ -78,9 +87,9 @@ type Handler struct {
lp *loop.Loop
screen_size loop.ScreenSize
all_items []DisplayItem
search_texts []string // parallel to all_items, for FZF scoring
matcher *fzf.FuzzyMatcher
filtered_idx []int // indices into all_items for current results
filtered_idx []int // indices into all_items for current results
match_infos []matchInfo // parallel to filtered_idx, valid when query != ""
query string
selected_idx int
scroll_offset int
@@ -176,13 +185,13 @@ func (h *Handler) flattenBindings() {
b.Category = catName
b.Mode = modeName
b.IsMouse = false
searchText := b.Key + " " + b.ActionDisplay + " " + catName
if modeName != "" {
searchText += " " + modeName
keyText := b.Key
if keyText == "" {
keyText = unmappedLabel
}
h.all_items = append(h.all_items, DisplayItem{
binding: b,
searchText: searchText,
binding: b,
colTexts: [3]string{keyText, b.ActionDisplay, catName},
})
}
}
@@ -193,18 +202,11 @@ func (h *Handler) flattenBindings() {
b.Category = "Mouse actions"
b.Mode = ""
b.IsMouse = true
searchText := b.Key + " " + b.ActionDisplay + " Mouse"
h.all_items = append(h.all_items, DisplayItem{
binding: b,
searchText: searchText,
binding: b,
colTexts: [3]string{b.Key, b.ActionDisplay, "Mouse actions"},
})
}
// Build parallel search texts array for FZF
h.search_texts = make([]string, len(h.all_items))
for i, item := range h.all_items {
h.search_texts[i] = item.searchText
}
}
func (h *Handler) updateFilter() {
@@ -214,34 +216,99 @@ func (h *Handler) updateFilter() {
for i := range h.all_items {
h.filtered_idx[i] = i
}
} else {
results, err := h.matcher.Score(h.search_texts, h.query)
if err != nil {
h.filtered_idx = nil
return
h.match_infos = nil
h.selected_idx = 0
h.scroll_offset = 0
return
}
nItems := len(h.all_items)
// Build per-column text slices for batch FZF scoring
colSlices := [3][]string{
make([]string, nItems),
make([]string, nItems),
make([]string, nItems),
}
for i, item := range h.all_items {
colSlices[0][i] = item.colTexts[0]
colSlices[1][i] = item.colTexts[1]
colSlices[2][i] = item.colTexts[2]
}
// Score each column independently
colResults := [3][]fzf.Result{}
for c := 0; c < 3; c++ {
results, err := h.matcher.Score(colSlices[c], h.query)
if err == nil {
colResults[c] = results
}
type scored struct {
idx int
score uint
}
var matches []scored
for i, r := range results {
if r.Score > 0 {
matches = append(matches, scored{idx: i, score: r.Score})
}
type scored struct {
idx int
score uint
colIdx int
positions []int
}
var matches []scored
for i := range h.all_items {
bestScore := uint(0)
bestCol := 0
var bestPositions []int
for c := 0; c < 3; c++ {
if colResults[c] != nil && i < len(colResults[c]) && colResults[c][i].Score > bestScore {
bestScore = colResults[c][i].Score
bestCol = c
bestPositions = colResults[c][i].Positions
}
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].score > matches[j].score
})
h.filtered_idx = make([]int, len(matches))
for i, m := range matches {
h.filtered_idx[i] = m.idx
if bestScore > 0 {
matches = append(matches, scored{idx: i, score: bestScore, colIdx: bestCol, positions: bestPositions})
}
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].score > matches[j].score
})
h.filtered_idx = make([]int, len(matches))
h.match_infos = make([]matchInfo, len(matches))
for i, m := range matches {
h.filtered_idx[i] = m.idx
h.match_infos[i] = matchInfo{colIdx: m.colIdx, positions: m.positions}
}
h.selected_idx = 0
h.scroll_offset = 0
}
// highlightMatchedChars returns a string with characters at the given rune
// positions rendered using matchStyle, and the rest rendered using baseStyle
// (or unstyled if baseStyle is empty).
func (h *Handler) highlightMatchedChars(text string, positions []int, baseStyle, matchStyle string) string {
if len(positions) == 0 {
if baseStyle != "" {
return h.lp.SprintStyled(baseStyle, text)
}
return text
}
posSet := make(map[int]bool, len(positions))
for _, p := range positions {
posSet[p] = true
}
runes := []rune(text)
var sb strings.Builder
for i, r := range runes {
ch := string(r)
if posSet[i] {
sb.WriteString(h.lp.SprintStyled(matchStyle, ch))
} else if baseStyle != "" {
sb.WriteString(h.lp.SprintStyled(baseStyle, ch))
} else {
sb.WriteString(ch)
}
}
return sb.String()
}
func (h *Handler) selectedBinding() *Binding {
if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) {
return nil
@@ -368,8 +435,12 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) {
})
}
// Binding line
keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth)
// Binding line — key column shows "(unmapped)" for actions with no shortcut
keyDisplay := b.Key
if keyDisplay == "" {
keyDisplay = unmappedLabel
}
keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth)
lines = append(lines, displayLine{
text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay),
itemIdx: fi,
@@ -381,11 +452,22 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) {
}
func (h *Handler) drawFlatResults(startY, maxRows, width int) {
if len(h.filtered_idx) == 0 {
h.lp.MoveCursorTo(1, startY)
h.lp.QueueWriteString(h.lp.SprintStyled("italic dim", " No matches found"))
h.display_lines = []displayLine{}
return
}
var lines []displayLine
for fi, idx := range h.filtered_idx {
item := &h.all_items[idx]
b := &item.binding
keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth)
keyDisplay := b.Key
if keyDisplay == "" {
keyDisplay = unmappedLabel
}
keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth)
catSuffix := ""
if b.Mode != "" {
catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category)
@@ -473,15 +555,64 @@ func (h *Handler) drawBindingLine(text string, filteredIdx, width int) {
}
b := &h.all_items[idx].binding
// Style the key portion green, leave action unstyled
keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth)
keyPrefix := fmt.Sprintf(" %-*s", maxKeyDisplayWidth, keyDisplay)
rest := ""
if len(text) > len(keyPrefix) {
rest = text[len(keyPrefix):]
// Build the key display (using unmappedLabel for items with no shortcut)
rawKey := b.Key
if rawKey == "" {
rawKey = unmappedLabel
}
keyDisplay := truncateToWidth(rawKey, maxKeyDisplayWidth)
// Determine match info for highlighting (only set when a query is active)
var mi *matchInfo
if h.query != "" && filteredIdx < len(h.match_infos) {
mi = &h.match_infos[filteredIdx]
}
const matchStyle = "fg=bright-yellow"
const keyStyle = "fg=green"
const unmappedStyle = "dim fg=green"
// Render key column (4-space indent + key padded to maxKeyDisplayWidth + space)
paddingLen := max(0, maxKeyDisplayWidth-wcswidth.Stringwidth(keyDisplay))
if mi != nil && mi.colIdx == 0 {
ks := keyStyle
if b.Key == "" {
ks = unmappedStyle
}
h.lp.QueueWriteString(" ")
h.lp.QueueWriteString(h.highlightMatchedChars(keyDisplay, mi.positions, ks, matchStyle))
h.lp.QueueWriteString(strings.Repeat(" ", paddingLen) + " ")
} else if b.Key == "" {
h.lp.QueueWriteString(h.lp.SprintStyled(unmappedStyle, " "+keyDisplay+strings.Repeat(" ", paddingLen)+" "))
} else {
h.lp.QueueWriteString(h.lp.SprintStyled(keyStyle, " "+keyDisplay+strings.Repeat(" ", paddingLen)+" "))
}
// Render action display column
if mi != nil && mi.colIdx == 1 {
h.lp.QueueWriteString(h.highlightMatchedChars(b.ActionDisplay, mi.positions, "", matchStyle))
} else {
h.lp.QueueWriteString(b.ActionDisplay)
}
// Render category suffix (only present in flat / search-results mode)
if h.query != "" {
if mi != nil && mi.colIdx == 2 {
if b.Mode != "" {
h.lp.QueueWriteString(fmt.Sprintf(" [%s/", b.Mode))
} else {
h.lp.QueueWriteString(" [")
}
h.lp.QueueWriteString(h.highlightMatchedChars(b.Category, mi.positions, "", matchStyle))
h.lp.QueueWriteString("]")
} else {
if b.Mode != "" {
h.lp.QueueWriteString(fmt.Sprintf(" [%s/%s]", b.Mode, b.Category))
} else {
h.lp.QueueWriteString(fmt.Sprintf(" [%s]", b.Category))
}
}
}
h.lp.QueueWriteString(h.lp.SprintStyled("fg=green", keyPrefix))
h.lp.QueueWriteString(rest)
}
// rowToFilteredIdx converts a 0-indexed cell Y coordinate to a filtered item

View File

@@ -10,7 +10,7 @@ from kitty.typing_compat import BossType
from ..tui.handler import result_handler
def collect_keys_data(opts: Any) -> dict[str, Any]:
def collect_keys_data(opts: Any, show_unmapped: bool = False) -> dict[str, Any]:
"""Collect all keybinding data from options into a JSON-serializable dict."""
from kitty.actions import get_all_actions, groups
from kitty.options.utils import KeyDefinition
@@ -97,6 +97,34 @@ def collect_keys_data(opts: Any) -> dict[str, Any]:
new_default_cats[cat_name] = keep
modes[''] = new_default_cats
# Optionally add unmapped actions (actions with no keyboard shortcut).
if show_unmapped:
# Collect all action names that already appear in a binding.
mapped_actions: set[str] = set()
for mode_cats in modes.values():
for bindings in mode_cats.values():
for b in bindings:
mapped_actions.add(b['action'])
default_mode_cats = modes.setdefault('', {})
for group_key, actions in get_all_actions().items():
category = groups[group_key]
for action in actions:
if action.name not in mapped_actions:
default_mode_cats.setdefault(category, []).append({
'key': '',
'action': action.name,
'action_display': action.name,
'definition': action.name,
'help': action.short_help,
'long_help': action.long_help,
})
# Re-sort each category: mapped entries (non-empty key) by key first,
# then unmapped entries (empty key) sorted by action name.
for cat in default_mode_cats:
default_mode_cats[cat].sort(key=lambda b: (b['key'] == '', b['key'] or b['action']))
# Emit explicit mode and category ordering since JSON maps lose insertion order
mode_order = list(modes.keys())
category_order: dict[str, list[str]] = {}
@@ -131,7 +159,11 @@ def handle_result(args: list[str], data: dict[str, Any], target_window_id: int,
help_text = 'Browse and trigger keyboard shortcuts and actions'
usage = ''
OPTIONS = ''.format
OPTIONS = r'''
--show-unmapped
type=bool-set
Also show actions that have not been mapped to a keyboard shortcut.
'''.format
if __name__ == '__main__':

View File

@@ -152,13 +152,17 @@ func TestFilterMatchesSubset(t *testing.T) {
if len(h.filtered_idx) >= len(h.all_items) {
t.Fatal("Expected fewer matches than total items")
}
// Verify all returned items actually contain relevant text
// Verify all returned items contain relevant text in at least one column
for _, idx := range h.filtered_idx {
text := strings.ToLower(h.all_items[idx].searchText)
if !strings.Contains(text, "clipboard") {
// FZF does fuzzy matching, so this is a soft check —
// the characters should at least be present
item := &h.all_items[idx]
found := false
for _, col := range item.colTexts {
if strings.Contains(strings.ToLower(col), "clipboard") {
found = true
break
}
}
_ = found // FZF does fuzzy matching, so this is a soft check
}
}
@@ -234,11 +238,16 @@ func TestSelectedBindingNilWhenOverflowIndex(t *testing.T) {
func TestSearchTextContainsKeyAndAction(t *testing.T) {
h := newTestHandler()
for i, item := range h.all_items {
if !strings.Contains(item.searchText, item.binding.Key) {
t.Fatalf("Item %d: search text %q should contain key %q", i, item.searchText, item.binding.Key)
// colTexts[0] = key (or unmappedLabel for empty key), [1] = action_display, [2] = category
expectedKey := item.binding.Key
if expectedKey == "" {
expectedKey = unmappedLabel
}
if !strings.Contains(item.searchText, item.binding.ActionDisplay) {
t.Fatalf("Item %d: search text %q should contain action %q", i, item.searchText, item.binding.ActionDisplay)
if !strings.Contains(item.colTexts[0], expectedKey) {
t.Fatalf("Item %d: colTexts[0] %q should contain key %q", i, item.colTexts[0], expectedKey)
}
if !strings.Contains(item.colTexts[1], item.binding.ActionDisplay) {
t.Fatalf("Item %d: colTexts[1] %q should contain action %q", i, item.colTexts[1], item.binding.ActionDisplay)
}
}
}
@@ -549,3 +558,122 @@ func TestScrollAdjustRevealsSectionHeader(t *testing.T) {
t.Fatalf("Expected scroll_offset=0 to show category header, got %d", h.scroll_offset)
}
}
func TestColTextsPopulated(t *testing.T) {
h := newTestHandler()
for i, item := range h.all_items {
if item.binding.IsMouse {
continue
}
expectedKey := item.binding.Key
if expectedKey == "" {
expectedKey = unmappedLabel
}
if item.colTexts[0] != expectedKey {
t.Fatalf("Item %d: colTexts[0]=%q expected %q", i, item.colTexts[0], expectedKey)
}
if item.colTexts[1] != item.binding.ActionDisplay {
t.Fatalf("Item %d: colTexts[1]=%q expected %q", i, item.colTexts[1], item.binding.ActionDisplay)
}
if item.colTexts[2] != item.binding.Category {
t.Fatalf("Item %d: colTexts[2]=%q expected %q", i, item.colTexts[2], item.binding.Category)
}
}
}
func TestFilterSingleColumnMatch(t *testing.T) {
// "scroll" is in action_display column only, not in key or category.
// With per-column matching it should still match the action column.
h := newTestHandler()
h.query = "scroll"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected matches for 'scroll' against action column")
}
// All matched items should have 'scroll' in exactly one column, not spread across columns
for _, idx := range h.filtered_idx {
item := &h.all_items[idx]
colMatch := false
for _, col := range item.colTexts {
if strings.Contains(strings.ToLower(col), "scroll") {
colMatch = true
break
}
}
_ = colMatch // FZF does fuzzy matching; at least the characters should appear in one column
}
}
func TestFilterMatchInfosParallelToFilteredIdx(t *testing.T) {
h := newTestHandler()
h.query = "clipboard"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected some matches")
}
if len(h.match_infos) != len(h.filtered_idx) {
t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx))
}
for i, mi := range h.match_infos {
if mi.colIdx < 0 || mi.colIdx > 2 {
t.Fatalf("match_infos[%d].colIdx=%d out of range [0,2]", i, mi.colIdx)
}
}
}
func TestFilterMatchInfosNilWhenNoQuery(t *testing.T) {
h := newTestHandler()
h.query = ""
h.updateFilter()
if h.match_infos != nil {
t.Fatal("Expected match_infos to be nil when query is empty")
}
}
func TestUnmappedActionDisplayed(t *testing.T) {
// Inject an item with an empty key (unmapped action) and verify display
h := &Handler{}
unmappedJSON := `{
"modes": {
"": {
"Miscellaneous": [
{"key": "ctrl+n", "action": "new_window", "action_display": "new_window", "definition": "new_window", "help": "Open new window", "long_help": ""},
{"key": "", "action": "scroll_home", "action_display": "scroll_home", "definition": "scroll_home", "help": "Scroll to top", "long_help": ""}
]
}
},
"mouse": [],
"mode_order": [""],
"category_order": {"": ["Miscellaneous"]}
}`
if err := json.Unmarshal([]byte(unmappedJSON), &h.input_data); err != nil {
t.Fatal(err)
}
h.flattenBindings()
h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME)
if len(h.all_items) != 2 {
t.Fatalf("Expected 2 items, got %d", len(h.all_items))
}
// Find the unmapped item
var unmapped *DisplayItem
for i := range h.all_items {
if h.all_items[i].binding.Key == "" {
unmapped = &h.all_items[i]
break
}
}
if unmapped == nil {
t.Fatal("Expected to find unmapped item")
}
// colTexts[0] should be unmappedLabel, not empty
if unmapped.colTexts[0] != unmappedLabel {
t.Fatalf("Expected colTexts[0]=%q for unmapped item, got %q", unmappedLabel, unmapped.colTexts[0])
}
// Should be searchable by action name
h.query = "scroll_home"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected unmapped action to be found by action name search")
}
}

View File

@@ -2302,10 +2302,20 @@ 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:
@ac('misc', '''
Browse and trigger keyboard shortcuts and actions in a searchable overlay.
Optionally pass :code:`show_unmapped` to also show actions that have no keyboard shortcut assigned::
# show only mapped actions (default)
map ctrl+shift+p command_palette
# also show unmapped actions
map ctrl+shift+alt+p command_palette show_unmapped
''')
def command_palette(self, *args: str) -> None:
from kittens.command_palette.main import collect_keys_data
data = collect_keys_data(get_options())
show_unmapped = 'show_unmapped' in args
data = collect_keys_data(get_options(), show_unmapped=show_unmapped)
self.run_kitten_with_metadata('command-palette', input_data=json.dumps(data), window=self.window_for_dispatch)
@ac(

View File

@@ -167,6 +167,7 @@ def detach_tab_parse(func: str, rest: str) -> FuncArgsType:
@func_with_args(
'set_background_opacity', 'goto_layout', 'toggle_layout', 'toggle_tab', 'kitty_shell', 'show_kitty_doc',
'set_tab_title', 'push_keyboard_mode', 'dump_lines_with_attrs', 'set_window_title', 'simulate_color_scheme_preference_change',
'command_palette',
)
def simple_parse(func: str, rest: str) -> FuncArgsType:
return func, (rest,)

View File

@@ -98,3 +98,62 @@ class TestCommandPalette(BaseTest):
set(data['modes'][mode_name].keys()),
f'category_order for mode {mode_name!r} should match modes keys',
)
def test_show_unmapped_includes_extra_actions(self):
from kittens.command_palette.main import collect_keys_data
from kitty.actions import get_all_actions
opts = self.set_options()
data_default = collect_keys_data(opts, show_unmapped=False)
data_unmapped = collect_keys_data(opts, show_unmapped=True)
# With show_unmapped=True, we should have at least as many bindings
def count_bindings(data: dict) -> int:
total = 0
for cats in data['modes'].values():
for bindings in cats.values():
total += len(bindings)
return total
count_default = count_bindings(data_default)
count_with_unmapped = count_bindings(data_unmapped)
self.assertTrue(
count_with_unmapped >= count_default,
'show_unmapped should not remove any existing bindings',
)
# There should be at least one unmapped action (empty key) in the result
found_unmapped = False
for cats in data_unmapped['modes'].values():
for bindings in cats.values():
for b in bindings:
if b['key'] == '':
found_unmapped = True
# Unmapped actions must still have action and definition
self.assertTrue(len(b['action']) > 0)
self.assertTrue(len(b['definition']) > 0)
break
self.assertTrue(found_unmapped, 'Expected at least one unmapped action')
def test_show_unmapped_false_has_no_empty_keys(self):
from kittens.command_palette.main import collect_keys_data
opts = self.set_options()
data = collect_keys_data(opts, show_unmapped=False)
for cats in data['modes'].values():
for bindings in cats.values():
for b in bindings:
self.assertTrue(
len(b['key']) > 0,
f'Without show_unmapped, all bindings should have non-empty keys; got {b!r}',
)
def test_show_unmapped_sorted_order(self):
from kittens.command_palette.main import collect_keys_data
opts = self.set_options()
data = collect_keys_data(opts, show_unmapped=True)
# In each category, mapped bindings (non-empty key) should come before unmapped ones
for cat_name, bindings in data['modes'].get('', {}).items():
seen_unmapped = False
for b in bindings:
if b['key'] == '':
seen_unmapped = True
elif seen_unmapped:
self.fail(
f'In category {cat_name!r}, mapped binding {b!r} follows an unmapped one'
)