Filter the keybindings menu as you type

Looking up a keybinding is a search, so the menu that lists them is the
one that most wants this. Its items do have keys, but only as a reminder
of what they do outside the menu, so nothing is lost by not binding them.

The prompt in front of the input field says what '@' does. It only ever
showed up while the user was typing in the search prompt, so it could
afford to be wordy; on a row that is on screen for as long as the menu
is, it can't.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-08-25 09:48:36 +02:00
parent 68355862c1
commit fb761892aa
8 changed files with 248 additions and 6 deletions

View file

@ -64,6 +64,7 @@ func (self *OptionsMenuAction) Call() error {
ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft},
AllowFilteringKeybindings: true,
KeepConflictingKeybindings: true,
FilterAsYouType: true,
})
}

View file

@ -2040,7 +2040,7 @@ func EnglishTranslationSet() *TranslationSet {
SearchKeybindings: "%s: Next match, %s: Previous match, %s: Exit search mode",
SearchPrefix: "Search: ",
FilterPrefix: "Filter: ",
FilterPrefixMenu: "Filter (prepend '@' to filter keybindings): ",
FilterPrefixMenu: "Filter ('@' for keybindings): ",
MenuFilterHint: "(Type to filter)",
WorktreesTitle: "Worktrees",
WorktreeTitle: "Worktree",

View file

@ -56,8 +56,12 @@ func (self *MenuDriver) ContainsLines(matchers ...*TextMatcher) *MenuDriver {
return self
}
// types the text into the menu's filter row. Only for menus that filter as you
// type; other menus are filtered through the search prompt.
func (self *MenuDriver) Filter(text string) *MenuDriver {
self.getViewDriver().FilterOrSearch(text)
self.getViewDriver().IsFocused()
self.t.typeContent(text)
self.t.Views().MenuFilter().IsVisible()
return self
}

View file

@ -0,0 +1,94 @@
package filter_and_search
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FilterMenuAsYouType = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Filtering a menu by typing into the filter row that appears as you type",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().IsFocused().Press(keys.Universal.OptionMenu)
// The menu offers the filter, but stays as it is until we take it up on it
t.Views().Menu().
IsFocused().
Subtitle(Equals("(Type to filter)")).
Footer(Contains(" of "))
t.Views().MenuFilter().IsInvisible()
t.Views().MenuFilterFrame().IsInvisible()
t.CursorIsHidden()
t.ExpectPopup().Menu().Filter("whitespace")
t.Views().Menu().
Lines(
Contains("─── Global"),
Contains("Toggle whitespace").IsSelected(),
).
// the row covers the border the footer was on, so it moves there
Subtitle(Equals("")).
Footer(Equals(""))
t.Views().MenuFilterFrame().
IsVisible().
Content(Equals("Filter ('@' for keybindings): ")).
Footer(Equals("1 of 1")).
SharesTopBorderWithBottomOf(t.Views().Menu())
t.Views().MenuFilter().IsVisible().Content(Equals("whitespace"))
t.Views().Tooltip().
IsVisible().
Content(Contains("Toggle whether or not whitespace changes are shown")).
IsImmediatelyBelow(t.Views().MenuFilterFrame())
t.CursorIsVisible()
// Emptying the filter shows all the items again, and keeps the row
t.GlobalPress(config.Keybinding{"<c-u>"})
t.Views().MenuFilter().IsVisible().Content(Equals(""))
t.Views().Menu().LineCount(GreaterThan(2))
t.CursorIsVisible()
// Moving the text cursor within the filter leaves the menu's selection alone
t.ExpectPopup().Menu().Filter("co")
t.Views().Menu().LineCount(GreaterThan(2))
t.GlobalPress(config.Keybinding{"<down>"})
t.Views().Menu().SelectedLineIdxAtLeast(2)
t.GlobalPress(config.Keybinding{"<left>"})
t.Views().Menu().SelectedLineIdxAtLeast(2)
t.GlobalPress(config.Keybinding{"<right>"})
// Clicking an item selects it and leaves the filter where it is
t.Views().Menu().Click(0, 1).SelectedLineIdx(1)
t.GlobalPress(config.Keybinding{"m"})
t.Views().MenuFilter().Content(Equals("com"))
t.GlobalPress(config.Keybinding{"<c-u>"})
// Escape gives up the filter, keeping the item that was selected
t.ExpectPopup().Menu().Filter("whitespace")
t.Views().Menu().SelectedLine(Contains("Toggle whitespace"))
t.GlobalPress(keys.Universal.Return)
t.Views().Menu().
IsFocused().
SelectedLine(Contains("Toggle whitespace")).
Subtitle(Equals("(Type to filter)")).
Footer(Contains(" of "))
t.Views().MenuFilter().IsInvisible()
t.Views().MenuFilterFrame().IsInvisible()
t.CursorIsHidden()
// The next escape closes the menu
t.GlobalPress(keys.Universal.Return)
t.Views().Files().IsFocused()
// A menu opened afterwards starts with no filter
t.Views().Files().Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
Title(Equals("Keybindings")).
LineCount(GreaterThan(2)).
Cancel()
},
})

View file

@ -0,0 +1,71 @@
package filter_and_search
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FilterMenuKeyHandling = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Which keys drive a menu that filters as you type, and which ones are filter text",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// so that quitting is observable instead of ending the test
cfg.GetUserConfig().ConfirmOnQuit = true
},
SetupRepo: func(shell *Shell) {},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// presses a key that is expected to move the selection away from the first
// item, and one that is expected to bring it back
navigates := func(forward string, back string) {
t.GlobalPress(config.Keybinding{forward})
t.Views().Menu().SelectedLineIdxAtLeast(2)
t.GlobalPress(config.Keybinding{back})
t.Views().Menu().SelectedLineIdx(1)
}
t.Views().Files().IsFocused().Press(keys.Universal.OptionMenu)
t.Views().Menu().IsFocused().SelectedLineIdx(1)
// Until there is a filter, the configured navigation keys drive the menu,
// printable or not
navigates("<down>", "<up>")
navigates("j", "k")
navigates(".", ",")
navigates(">", "<")
t.Views().MenuFilter().IsInvisible()
// A menu item's own key is filter text; it doesn't execute the item. 'c'
// commits when the files view has the focus.
t.ExpectPopup().Menu().Filter("c")
t.Views().Menu().IsFocused()
t.Views().MenuFilter().Content(Equals("c"))
// So is the key that filters other lists
t.GlobalPress(keys.Universal.StartSearch)
t.Views().MenuFilter().Content(Equals("c/"))
t.Views().Search().IsInvisible()
// And so are the printable navigation keys, now that there is somewhere for
// them to go
t.GlobalPress(config.Keybinding{"<c-u>"})
t.GlobalPress(config.Keybinding{"j"})
t.GlobalPress(config.Keybinding{"."})
t.GlobalPress(config.Keybinding{">"})
t.Views().MenuFilter().Content(Equals("j.>"))
// The keys that can't be typed keep driving the menu
t.GlobalPress(config.Keybinding{"<c-u>"})
navigates("<down>", "<up>")
navigates("<pgdown>", "<pgup>")
navigates("<end>", "<home>")
// Keys that the filter doesn't take and the menu doesn't handle reach the
// global keybindings
t.GlobalPress(config.Keybinding{"<c-c>"})
t.ExpectPopup().Confirmation().
Title(Equals("")).
Content(Contains("Are you sure you want to quit?")).
Confirm()
},
})

View file

@ -0,0 +1,66 @@
package filter_and_search
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FilterMenuWithPrintableKeybindings = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Driving a menu that filters as you type when every key configured for it is printable",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().Keybinding.Universal.ConfirmMenu = config.Keybinding{"x"}
cfg.GetUserConfig().Keybinding.Universal.Return = config.Keybinding{"q"}
cfg.GetUserConfig().Keybinding.Universal.PrevItem = config.Keybinding{"k"}
cfg.GetUserConfig().Keybinding.Universal.NextItem = config.Keybinding{"j"}
cfg.GetUserConfig().Keybinding.Universal.PrevPage = config.Keybinding{"u"}
cfg.GetUserConfig().Keybinding.Universal.NextPage = config.Keybinding{"d"}
cfg.GetUserConfig().Keybinding.Universal.GotoTop = config.Keybinding{"g"}
cfg.GetUserConfig().Keybinding.Universal.GotoBottom = config.Keybinding{"G"}
},
SetupRepo: func(shell *Shell) {},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
navigates := func(forward string, back string) {
t.GlobalPress(config.Keybinding{forward})
t.Views().Menu().SelectedLineIdxAtLeast(2)
t.GlobalPress(config.Keybinding{back})
t.Views().Menu().SelectedLineIdx(1)
}
t.Views().Files().IsFocused().Press(keys.Universal.OptionMenu)
t.Views().Menu().IsFocused().SelectedLineIdx(1)
// Until there is a filter, the configured keys drive the menu
navigates("j", "k")
navigates("d", "u")
navigates("G", "g")
// Once there is one, they are all filter text. It takes a key that isn't a
// navigation key to get there.
t.ExpectPopup().Menu().Filter("a")
t.GlobalPress(config.Keybinding{"j"})
t.GlobalPress(config.Keybinding{"k"})
t.GlobalPress(config.Keybinding{"d"})
t.GlobalPress(config.Keybinding{"u"})
t.Views().MenuFilter().Content(Equals("ajkdu"))
t.GlobalPress(config.Keybinding{"<c-u>"})
// The menu is still navigable, because the physical keys drive it whatever
// the configuration says
navigates("<down>", "<up>")
navigates("<pgdown>", "<pgup>")
navigates("<end>", "<home>")
// And so are confirming and cancelling. Escape gives up the filter first.
t.ExpectPopup().Menu().Filter("Toggle whitespace")
t.GlobalPress(config.Keybinding{"<esc>"})
t.Views().MenuFilter().IsInvisible()
t.Views().Menu().IsFocused().SelectedLine(Contains("Toggle whitespace"))
t.ExpectPopup().Menu().Filter("Toggle whitespace")
t.Views().Menu().SelectedLine(Contains("Toggle whitespace"))
t.GlobalPress(config.Keybinding{"<enter>"})
t.Views().Files().IsFocused()
},
})

View file

@ -262,9 +262,12 @@ var tests = []*components.IntegrationTest{
filter_and_search.FilterFilesStageDirectory,
filter_and_search.FilterFuzzy,
filter_and_search.FilterMenu,
filter_and_search.FilterMenuAsYouType,
filter_and_search.FilterMenuByKeybinding,
filter_and_search.FilterMenuCancelFilterWithEscape,
filter_and_search.FilterMenuKeyHandling,
filter_and_search.FilterMenuWithNoKeybindings,
filter_and_search.FilterMenuWithPrintableKeybindings,
filter_and_search.FilterPreservesSelectionOnModelChange,
filter_and_search.FilterRemoteBranches,
filter_and_search.FilterRemotes,

View file

@ -17,16 +17,19 @@ var EmptyMenu = NewIntegrationTest(NewIntegrationTestArgs{
IsFocused().
Press(keys.Universal.OptionMenu)
t.ExpectPopup().Menu().
// a string that filters everything out
Filter("ljasldkjaslkdjalskdjalsdjaslkd")
t.Views().Menu().
IsFocused().
// a string that filters everything out
FilterOrSearch("ljasldkjaslkdjalskdjalsdjaslkd").
IsEmpty().
Press(keys.Universal.Select).
// space is filter text in this menu, so we confirm with enter
Press(keys.Universal.ConfirmMenu).
Tap(func() {
t.ExpectToast(Equals("Disabled: No item selected"))
}).
// escape the search
// escape the filter
PressEscape().
// escape the view
PressEscape()