Make showing of unmapped actions a runtime cached setting

Fixes #9591
This commit is contained in:
copilot-swe-agent[bot] 2026-03-03 09:07:22 +00:00 committed by Kovid Goyal
parent f13c8cd44d
commit 5638d20921
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
5 changed files with 168 additions and 84 deletions

View file

@ -15,6 +15,7 @@ import (
"github.com/kovidgoyal/kitty/tools/tty"
"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"
)
@ -83,6 +84,11 @@ func truncateToWidth(s string, maxWidth int) string {
return string(runes) + "..."
}
// CachedSettings holds persistent UI settings stored in command-palette.json.
type CachedSettings struct {
ShowUnmapped bool `json:"show_unmapped"`
}
type Handler struct {
lp *loop.Loop
screen_size loop.ScreenSize
@ -98,6 +104,8 @@ type Handler struct {
display_lines []displayLine
results_start_y int
results_height int
show_unmapped bool
cv *utils.CachedValues[*CachedSettings]
}
func (h *Handler) initialize() (string, error) {
@ -111,6 +119,12 @@ func (h *Handler) initialize() (string, error) {
h.lp.AllowLineWrapping(false)
h.lp.SetWindowTitle("Command Palette")
// Initialize with ShowUnmapped: true as the default; Load() returns this
// default when no cache file exists yet.
h.cv = utils.NewCachedValues("command-palette", &CachedSettings{ShowUnmapped: true})
settings := h.cv.Load()
h.show_unmapped = settings.ShowUnmapped
if err := h.loadData(); err != nil {
return "", err
}
@ -211,10 +225,13 @@ func (h *Handler) flattenBindings() {
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
// Show all items in original order, respecting the show_unmapped toggle
h.filtered_idx = make([]int, 0, len(h.all_items))
for i, item := range h.all_items {
if !h.show_unmapped && item.binding.Key == "" {
continue
}
h.filtered_idx = append(h.filtered_idx, i)
}
h.match_infos = nil
h.selected_idx = 0
@ -253,6 +270,9 @@ func (h *Handler) updateFilter() {
}
var matches []scored
for i := range h.all_items {
if !h.show_unmapped && h.all_items[i].binding.Key == "" {
continue
}
bestScore := uint(0)
bestCol := 0
var bestPositions []int
@ -378,9 +398,14 @@ func (h *Handler) draw_screen() {
// Draw key hints footer
h.lp.MoveCursorTo(1, hintsY)
unmappedToggleLabel := "Show"
if h.show_unmapped {
unmappedToggleLabel = "Hide"
}
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"
h.lp.SprintStyled("fg=bright-yellow", "\u2191\u2193") + " Navigate " +
h.lp.SprintStyled("fg=bright-yellow", "[F12]") + " " + unmappedToggleLabel + " unmapped"
matchInfo := ""
if h.query != "" {
matchInfo = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items))
@ -716,6 +741,17 @@ func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error {
}
return nil
}
if ev.MatchesPressOrRepeat("f12") {
ev.Handled = true
h.show_unmapped = !h.show_unmapped
if h.cv != nil {
h.cv.Opts.ShowUnmapped = h.show_unmapped
h.cv.Save()
}
h.updateFilter()
h.draw_screen()
return nil
}
return nil
}

View file

@ -10,7 +10,7 @@
from ..tui.handler import result_handler
def collect_keys_data(opts: Any, show_unmapped: bool = False) -> dict[str, Any]:
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
@ -97,33 +97,43 @@ def as_sc(k: 'Any', v: KeyDefinition) -> Shortcut:
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'])
# Add unmapped actions (actions with no keyboard shortcut).
# 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,
})
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']))
# 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']))
# Re-order default_mode_cats by groups ordering (adding unmapped actions may
# have appended new categories at the end, breaking the established order).
reordered: dict[str, list[dict[str, str]]] = {}
for group_title in groups.values():
if group_title in default_mode_cats:
reordered[group_title] = default_mode_cats[group_title]
for cat_name, binds in default_mode_cats.items():
if cat_name not in reordered:
reordered[cat_name] = binds
modes[''] = reordered
# Emit explicit mode and category ordering since JSON maps lose insertion order
mode_order = list(modes.keys())
@ -160,9 +170,6 @@ 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 = r'''
--show-unmapped
type=bool-set
Also show actions that have not been mapped to a keyboard shortcut.
'''.format

View file

@ -130,10 +130,11 @@ func TestMouseBindingsMarkedCorrectly(t *testing.T) {
func TestFilterNoQueryReturnsAll(t *testing.T) {
h := newTestHandler()
h.show_unmapped = true // show all items including unmapped
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))
t.Fatalf("With no query and show_unmapped=true, expected %d items, got %d", len(h.all_items), len(h.filtered_idx))
}
for i, idx := range h.filtered_idx {
if idx != i {
@ -670,10 +671,77 @@ func TestUnmappedActionDisplayed(t *testing.T) {
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
// With show_unmapped=true, unmapped action should be searchable
h.show_unmapped = true
h.query = "scroll_home"
h.updateFilter()
if len(h.filtered_idx) == 0 {
t.Fatal("Expected unmapped action to be found by action name search")
t.Fatal("Expected unmapped action to be found by action name search when show_unmapped=true")
}
// With show_unmapped=false, unmapped action should be hidden
h.show_unmapped = false
h.query = ""
h.updateFilter()
for _, idx := range h.filtered_idx {
if h.all_items[idx].binding.Key == "" {
t.Fatal("Expected unmapped action to be hidden when show_unmapped=false")
}
}
}
func TestShowUnmappedToggle(t *testing.T) {
// TestShowUnmappedToggle creates a handler with both mapped and unmapped items
// and verifies that the show_unmapped flag correctly filters the display.
h := &Handler{}
mixedJSON := `{
"modes": {
"": {
"Copy/paste": [
{"key": "ctrl+c", "action": "copy", "action_display": "copy", "definition": "copy", "help": "Copy", "long_help": ""},
{"key": "", "action": "paste_from_buffer", "action_display": "paste_from_buffer", "definition": "paste_from_buffer", "help": "Paste from buffer", "long_help": ""}
]
}
},
"mouse": [],
"mode_order": [""],
"category_order": {"": ["Copy/paste"]}
}`
if err := json.Unmarshal([]byte(mixedJSON), &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 in all_items, got %d", len(h.all_items))
}
// With show_unmapped=false, only mapped items should appear
h.show_unmapped = false
h.updateFilter()
if len(h.filtered_idx) != 1 {
t.Fatalf("With show_unmapped=false, expected 1 item, got %d", len(h.filtered_idx))
}
if h.all_items[h.filtered_idx[0]].binding.Key == "" {
t.Fatal("Filtered item should not be unmapped when show_unmapped=false")
}
// With show_unmapped=true, both items should appear
h.show_unmapped = true
h.updateFilter()
if len(h.filtered_idx) != 2 {
t.Fatalf("With show_unmapped=true, expected 2 items, got %d", len(h.filtered_idx))
}
// Toggle back to false with a query active; unmapped should still be hidden
h.show_unmapped = false
h.query = "paste"
h.updateFilter()
for _, idx := range h.filtered_idx {
if h.all_items[idx].binding.Key == "" {
t.Fatal("Unmapped item should not appear in search results when show_unmapped=false")
}
}
}

View file

@ -2304,18 +2304,10 @@ def input_unicode_character(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:
def command_palette(self) -> None:
from kittens.command_palette.main import collect_keys_data
show_unmapped = 'show_unmapped' in args
data = collect_keys_data(get_options(), show_unmapped=show_unmapped)
data = collect_keys_data(get_options())
self.run_kitten_with_metadata('command-palette', input_data=json.dumps(data), window=self.window_for_dispatch)
@ac(

View file

@ -33,7 +33,7 @@ def test_collect_keys_data(self):
self.assertIn('long_help', b)
self.assertIsInstance(b['key'], str)
self.assertIsInstance(b['action'], str)
self.assertTrue(len(b['key']) > 0)
# key may be empty for unmapped actions; action must always be non-empty
self.assertTrue(len(b['action']) > 0)
# Mouse mappings
self.assertIsInstance(data['mouse'], list)
@ -61,9 +61,17 @@ 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)
# Within each category, mapped entries (non-empty key) come first sorted by key,
# then unmapped entries (empty key) sorted by action name.
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')
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'
)
def test_collect_keys_has_help_text(self):
from kittens.command_palette.main import collect_keys_data
@ -99,28 +107,13 @@ def test_ordering_arrays_present(self):
f'category_order for mode {mode_name!r} should match modes keys',
)
def test_show_unmapped_includes_extra_actions(self):
def test_always_includes_unmapped_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
data = collect_keys_data(opts)
# Unmapped actions (empty key) are always included
found_unmapped = False
for cats in data_unmapped['modes'].values():
for cats in data['modes'].values():
for bindings in cats.values():
for b in bindings:
if b['key'] == '':
@ -129,24 +122,12 @@ def count_bindings(data: dict) -> int:
self.assertTrue(len(b['action']) > 0)
self.assertTrue(len(b['definition']) > 0)
break
self.assertTrue(found_unmapped, 'Expected at least one unmapped action')
self.assertTrue(found_unmapped, 'Expected at least one unmapped action to always be present')
def test_show_unmapped_false_has_no_empty_keys(self):
def test_unmapped_actions_sorted_order(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)
data = collect_keys_data(opts)
# In each category, mapped bindings (non-empty key) should come before unmapped ones
for cat_name, bindings in data['modes'].get('', {}).items():
seen_unmapped = False