Repository: jesseduffield/lazygit (language: go)

Issue:
Custom command and custom command
**Topic**
1. Rename the 'Custom command' thing associated with `:` to Execute Shell Command or Shell command.
2. Bring Custom command placeholders like `{{ .SelectedPath }} to Shell command.

**Your thoughts**
1. We have two features named Custom command. It's hard to refer clearly to which one when we use the 'custom command' keyword. Like this context.
I propose to use:
    - Custom command: for custom command defined in config.yml.
    - Shell command: execute the shell command that user inputs.

2. Another thought, we cannot use placeholders in Shell command. But it is convenient to provide this feature.
    - Use case 1:
        To execute a git command over the current selected file, what I want is:
        ```
        git --update-index --skip-worktree {{ .SelectedPath }}
        ```

        The file is modified and selected in Files tab, I hope I can pass the selected file path to execute arbitrary commands.

    - Use case 2:
        To change file attribute, add execute permission. Example command:
        ```
        chmod +x {{ .SelectedPath }}
        ```






Discussion / hints:
I very much like the first suggestion; the double use of the term "Custom Command" has often bothered me. Here's a PR: #3800.

I'm not so sure about the value of the second suggestion, but I wouldn't be opposed; seems like it shouldn't be too hard to implement it.
The first issue is fixed by #3800.

For the second, I have the feeling that what you really want is something else. The `:` command prompt is for typing ad-hoc commands, and you wouldn't want to type something like `{{ .SelectedPath }}` at that prompt, this is too cumbersome. It would be faster to select the file, hit `y p` to copy its path, and then type `: chmod +x <Command-V>`.

So it seems that you want to "abuse" the `:` command prompt for something more permanent; you want to use the `:` history for preconfiguring a command for later re-use. Normally you could just configure a custom command for these, but probably you don't want to sacrifice a keybinding for them, or you may have a hard time remembering the keybindings for those commands that you don't use daily.

I feel it might be worth adding a configuration that allows configuring a key binding for a list of custom commands instead of just one. Pressing the key would bring up a menu that lists all the configured commands for that key, with filtering, very similar to the `:` dialog.

This would be very similar to what you are suggesting, but it would clearly separate those pre-configured commands from the commands that you really type ad-hoc.

But it may well be easier to do what you suggested; just wanted to bring up this thought for discussion.
FWIW - I think the ability to search for custom commands by name without needing to bind them to a key would be a great enhancement.

I think it would be nice if there was a single command search functionality that suggested results from various sources (built-in, custom command, unbound custom command, recent ad-hoc, etc). But, I'd take the functionality however it comes. If this is something that could be a good fit for a PR from the community, I could take a stab at it
> FWIW - I think the ability to search for custom commands by name without needing to bind them to a key would be a great enhancement.

@a-jentleman that's already possible, you can search in the help (`?`) menu with `/`.

![image](https://github.com/user-attachments/assets/38456331-ef3e-4a15-bf3f-09741da840b6)

> @a-jentleman that's already possible, you can search in the help (`?`) menu with `/`.

Oh, sweet. Wish I tried this earlier. 

Is this a known & supported setup? If so, perhaps my contribution would be small doc changes to [Custom_Command_Keybindings.md](https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Command_Keybindings.md) and/or [Custom-Commands-Compendium](https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium) to demonstrate it
> If so, perhaps my contribution would be small doc changes to Custom_Command_Keybindings.md and/or Custom-Commands-Compendium to demonstrate it

Huh, it looks like the docs state `key` as `required` which it obviously isn't, so I guess that needs changing, yeah.

Fix patch:
diff --git a/docs/Custom_Command_Keybindings.md b/docs/Custom_Command_Keybindings.md
index b940a7bb3c4..265e86eb93b 100644
--- a/docs/Custom_Command_Keybindings.md
+++ b/docs/Custom_Command_Keybindings.md
@@ -1,6 +1,6 @@
 # Custom Command Keybindings
 
-You can add custom command keybindings in your config.yml (accessible by pressing 'o' on the status panel from within lazygit) like so:
+You can add custom command keybindings in your config.yml (accessible by pressing 'e' on the status panel from within lazygit) like so:
 
 ```yml
 customCommands:
@@ -324,6 +324,27 @@ We don't support accessing all elements of a range selection yet. We might add t
 
 If your custom keybinding collides with an inbuilt keybinding that is defined for the same context, only the custom keybinding will be executed. This also applies to the global context. However, one caveat is that if you have a custom keybinding defined on the global context for some key, and there is an in-built keybinding defined for the same key and for a specific context (say the 'files' context), then the in-built keybinding will take precedence. See how to change in-built keybindings [here](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#keybindings)
 
+## Menus of custom commands
+
+For custom commands that are not used very frequently it may be preferable to hide them in a menu; you can assign a key to open the menu, and the commands will appear inside. This has the advantage that you don't have to come up with individual unique keybindings for all those commands that you don't use often; the keybindings for the commands in the menu only need to be unique within the menu. Here is an example:
+
+```yml
+customCommands:
+- key: X
+  description: "Copy/paste commits across repos"
+  commandMenu:
+  - key: c
+    command: 'git format-patch --stdout {{.SelectedCommitRange.From}}^..{{.SelectedCommitRange.To}} | pbcopy'
+    context: commits, subCommits
+    description: "Copy selected commits to clipboard"
+  - key: v
+    command: 'pbpaste | git am'
+    context: "commits"
+    description: "Paste selected commits from clipboard"
+```
+
+If you use the commandMenu property, none of the other properties except key and description can be used.
+
 ## Debugging
 
 If you want to verify that your command actually does what you expect, you can wrap it in an 'echo' call and set `showOutput: true` so that it doesn't actually execute the command but you can see how the placeholders were resolved.
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index 116f634875c..77c7d4ead69 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -614,12 +614,16 @@ type CustomCommandAfterHook struct {
 type CustomCommand struct {
 	// The key to trigger the command. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md
 	Key string `yaml:"key"`
+	// Instead of defining a single custom command, create a menu of custom commands. Useful for grouping related commands together under a single keybinding, and for keeping them out of the global keybindings menu.
+	// When using this, all other fields except Key and Description are ignored and must be empty.
+	CommandMenu []CustomCommand `yaml:"commandMenu"`
 	// The context in which to listen for the key. Valid values are: status, files, worktrees, localBranches, remotes, remoteBranches, tags, commits, reflogCommits, subCommits, commitFiles, stash, and global. Multiple contexts separated by comma are allowed; most useful for "commits, subCommits" or "files, commitFiles".
 	Context string `yaml:"context" jsonschema:"example=status,example=files,example=worktrees,example=localBranches,example=remotes,example=remoteBranches,example=tags,example=commits,example=reflogCommits,example=subCommits,example=commitFiles,example=stash,example=global"`
 	// The command to run (using Go template syntax for placeholder values)
 	Command string `yaml:"command" jsonschema:"example=git fetch {{.Form.Remote}} {{.Form.Branch}} && git checkout FETCH_HEAD"`
 	// If true, run the command in a subprocess (e.g. if the command requires user input)
-	Subprocess bool `yaml:"subprocess"`
+	// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
+	Subprocess *bool `yaml:"subprocess"`
 	// A list of prompts that will request user input before running the final command
 	Prompts []CustomCommandPrompt `yaml:"prompts"`
 	// Text to display while waiting for command to finish
@@ -627,13 +631,24 @@ type CustomCommand struct {
 	// Label for the custom command when displayed in the keybindings menu
 	Description string `yaml:"description"`
 	// If true, stream the command's output to the Command Log panel
-	Stream bool `yaml:"stream"`
+	// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
+	Stream *bool `yaml:"stream"`
 	// If true, show the command's output in a popup within Lazygit
-	ShowOutput bool `yaml:"showOutput"`
+	// [dev] Pointer to bool so that we can distinguish unset (nil) from false.
+	ShowOutput *bool `yaml:"showOutput"`
 	// The title to display in the popup panel if showOutput is true. If left unset, the command will be used as the title.
 	OutputTitle string `yaml:"outputTitle"`
 	// Actions to take after the command has completed
-	After CustomCommandAfterHook `yaml:"after"`
+	// [dev] Pointer so that we can tell whether it appears in the config file
+	After *CustomCommandAfterHook `yaml:"after"`
+}
+
+func (c *CustomCommand) GetDescription() string {
+	if c.Description != "" {
+		return c.Description
+	}
+
+	return c.Command
 }
 
 type CustomCommandPrompt struct {
diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go
index 735c5aad2c2..2be2f04e9ea 100644
--- a/pkg/config/user_config_validation.go
+++ b/pkg/config/user_config_validation.go
@@ -96,6 +96,23 @@ func validateCustomCommands(customCommands []CustomCommand) error {
 		if err := validateCustomCommandKey(customCommand.Key); err != nil {
 			return err
 		}
+
+		if len(customCommand.CommandMenu) > 0 &&
+			(len(customCommand.Context) > 0 ||
+				len(customCommand.Command) > 0 ||
+				customCommand.Subprocess != nil ||
+				len(customCommand.Prompts) > 0 ||
+				len(customCommand.LoadingText) > 0 ||
+				customCommand.Stream != nil ||
+				customCommand.ShowOutput != nil ||
+				len(customCommand.OutputTitle) > 0 ||
+				customCommand.After != nil) {
+			commandRef := ""
+			if len(customCommand.Key) > 0 {
+				commandRef = fmt.Sprintf(" with key '%s'", customCommand.Key)
+			}
+			return fmt.Errorf("Error with custom command%s: it is not allowed to use both commandMenu and any of the other fields except key and description.", commandRef)
+		}
 	}
 	return nil
 }
diff --git a/pkg/gui/services/custom_commands/client.go b/pkg/gui/services/custom_commands/client.go
index 6cb1bf19c7b..12d8c78622f 100644
--- a/pkg/gui/services/custom_commands/client.go
+++ b/pkg/gui/services/custom_commands/client.go
@@ -1,15 +1,19 @@
 package custom_commands
 
 import (
-	"github.com/jesseduffield/lazygit/pkg/common"
+	"github.com/jesseduffield/gocui"
+	"github.com/jesseduffield/lazygit/pkg/config"
 	"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
+	"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
+	"github.com/jesseduffield/lazygit/pkg/i18n"
+	"github.com/samber/lo"
 )
 
 // Client is the entry point to this package. It returns a list of keybindings based on the config's user-defined custom commands.
 // See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Command_Keybindings.md for more info.
 type Client struct {
-	c                 *common.Common
+	c                 *helpers.HelperCommon
 	handlerCreator    *HandlerCreator
 	keybindingCreator *KeybindingCreator
 }
@@ -28,7 +32,7 @@ func NewClient(
 	keybindingCreator := NewKeybindingCreator(c)
 
 	return &Client{
-		c:                 c.Common,
+		c:                 c,
 		keybindingCreator: keybindingCreator,
 		handlerCreator:    handlerCreator,
 	}
@@ -37,13 +41,81 @@ func NewClient(
 func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) {
 	bindings := []*types.Binding{}
 	for _, customCommand := range self.c.UserConfig().CustomCommands {
-		handler := self.handlerCreator.call(customCommand)
-		compoundBindings, err := self.keybindingCreator.call(customCommand, handler)
-		if err != nil {
-			return nil, err
+		if len(customCommand.CommandMenu) > 0 {
+			handler := func() error {
+				return self.showCustomCommandsMenu(customCommand)
+			}
+			bindings = append(bindings, &types.Binding{
+				ViewName:    "", // custom commands menus are global; we filter the commands inside by context
+				Key:         keybindings.GetKey(customCommand.Key),
+				Modifier:    gocui.ModNone,
+				Handler:     handler,
+				Description: getCustomCommandsMenuDescription(customCommand, self.c.Tr),
+				OpensMenu:   true,
+			})
+		} else {
+			handler := self.handlerCreator.call(customCommand)
+			compoundBindings, err := self.keybindingCreator.call(customCommand, handler)
+			if err != nil {
+				return nil, err
+			}
+			bindings = append(bindings, compoundBindings...)
 		}
-		bindings = append(bindings, compoundBindings...)
 	}
 
 	return bindings, nil
 }
+
+func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) error {
+	menuItems := make([]*types.MenuItem, 0, len(customCommand.CommandMenu))
+	for _, subCommand := range customCommand.CommandMenu {
+		if len(subCommand.CommandMenu) > 0 {
+			handler := func() error {
+				return self.showCustomCommandsMenu(subCommand)
+			}
+			menuItems = append(menuItems, &types.MenuItem{
+				Label:     subCommand.GetDescription(),
+				Key:       keybindings.GetKey(subCommand.Key),
+				OnPress:   handler,
+				OpensMenu: true,
+			})
+		} else {
+			if subCommand.Context != "" && subCommand.Context != "global" {
+				viewNames, err := self.keybindingCreator.getViewNamesAndContexts(subCommand)
+				if err != nil {
+					return err
+				}
+
+				currentView := self.c.GocuiGui().CurrentView()
+				enabled := currentView != nil && lo.Contains(viewNames, currentView.Name())
+				if !enabled {
+					continue
+				}
+			}
+
+			menuItems = append(menuItems, &types.MenuItem{
+				Label:   subCommand.GetDescription(),
+				Key:     keybindings.GetKey(subCommand.Key),
+				OnPress: self.handlerCreator.call(subCommand),
+			})
+		}
+	}
+
+	if len(menuItems) == 0 {
+		menuItems = append(menuItems, &types.MenuItem{
+			Label:   self.c.Tr.NoApplicableCommandsInThisContext,
+			OnPress: func() error { return nil },
+		})
+	}
+
+	title := getCustomCommandsMenuDescription(customCommand, self.c.Tr)
+	return self.c.Menu(types.CreateMenuOptions{Title: title, Items: menuItems, HideCancel: true})
+}
+
+func getCustomCommandsMenuDescription(customCommand config.CustomCommand, tr *i18n.TranslationSet) string {
+	if customCommand.Description != "" {
+		return customCommand.Description
+	}
+
+	return tr.CustomCommands
+}
diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go
index 95de40a2e9a..9e17ca9fa81 100644
--- a/pkg/gui/services/custom_commands/handler_creator.go
+++ b/pkg/gui/services/custom_commands/handler_creator.go
@@ -261,7 +261,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
 
 	cmdObj := self.c.OS().Cmd.NewShell(cmdStr)
 
-	if customCommand.Subprocess {
+	if customCommand.Subprocess != nil && *customCommand.Subprocess {
 		return self.c.RunSubprocessAndRefresh(cmdObj)
 	}
 
@@ -273,7 +273,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
 	return self.c.WithWaitingStatus(loadingText, func(gocui.Task) error {
 		self.c.LogAction(self.c.Tr.Actions.CustomCommand)
 
-		if customCommand.Stream {
+		if customCommand.Stream != nil && *customCommand.Stream {
 			cmdObj.StreamOutput()
 		}
 		output, err := cmdObj.RunWithOutput()
@@ -283,14 +283,14 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
 		}
 
 		if err != nil {
-			if customCommand.After.CheckForConflicts {
+			if customCommand.After != nil && customCommand.After.CheckForConflicts {
 				return self.mergeAndRebaseHelper.CheckForConflicts(err)
 			}
 
 			return err
 		}
 
-		if customCommand.ShowOutput {
+		if customCommand.ShowOutput != nil && *customCommand.ShowOutput {
 			if strings.TrimSpace(output) == "" {
 				output = self.c.Tr.EmptyOutput
 			}
diff --git a/pkg/gui/services/custom_commands/keybinding_creator.go b/pkg/gui/services/custom_commands/keybinding_creator.go
index 4e6f9d8c777..259954c17bb 100644
--- a/pkg/gui/services/custom_commands/keybinding_creator.go
+++ b/pkg/gui/services/custom_commands/keybinding_creator.go
@@ -34,18 +34,13 @@ func (self *KeybindingCreator) call(customCommand config.CustomCommand, handler
 		return nil, err
 	}
 
-	description := customCommand.Description
-	if description == "" {
-		description = customCommand.Command
-	}
-
 	return lo.Map(viewNames, func(viewName string, _ int) *types.Binding {
 		return &types.Binding{
 			ViewName:    viewName,
 			Key:         keybindings.GetKey(customCommand.Key),
 			Modifier:    gocui.ModNone,
 			Handler:     handler,
-			Description: description,
+			Description: customCommand.GetDescription(),
 		}
 	}), nil
 }
diff --git a/pkg/gui/services/custom_commands/models.go b/pkg/gui/services/custom_commands/models.go
index 261bace45ac..eee52ff5d0b 100644
--- a/pkg/gui/services/custom_commands/models.go
+++ b/pkg/gui/services/custom_commands/models.go
@@ -14,8 +14,8 @@ import (
 // compatibility. We already did this for Commit.Sha, which was renamed to Hash.
 
 type Commit struct {
-	Hash          string // deprecated: use Sha
-	Sha           string
+	Hash          string
+	Sha           string // deprecated: use Hash
 	Name          string
 	Status        models.CommitStatus
 	Action        todo.TodoCommand
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index f405061b4f3..81620e47b24 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -843,6 +843,8 @@ type TranslationSet struct {
 	RangeSelectNotSupportedForSubmodules     string
 	OldCherryPickKeyWarning                  string
 	CommandDoesNotSupportOpeningInEditor     string
+	CustomCommands                           string
+	NoApplicableCommandsInThisContext        string
 	Actions                                  Actions
 	Bisect                                   Bisect
 	Log                                      Log
@@ -1879,6 +1881,8 @@ func EnglishTranslationSet() *TranslationSet {
 		RangeSelectNotSupportedForSubmodules:     "Range select not supported for submodules",
 		OldCherryPickKeyWarning:                  "The 'c' key is no longer the default key for copying commits to cherry pick. Please use `{{.copy}}` instead (and `{{.paste}}` to paste). The reason for this change is that the 'v' key for selecting a range of lines when staging is now also used for selecting a range of lines in any list view, meaning that we needed to find a new key for pasting commits, and if we're going to now use `{{.paste}}` for pasting commits, we may as well use `{{.copy}}` for copying them. If you want to configure the keybindings to get the old behaviour, set the following in your config:\n\nkeybinding:\n  universal:\n    toggleRangeSelect: <something other than v>\n  commits:\n    cherryPickCopy: 'c'\n    pasteCommits: 'v'",
 		CommandDoesNotSupportOpeningInEditor:     "This command doesn't support switching to the editor",
+		CustomCommands:                           "Custom commands",
+		NoApplicableCommandsInThisContext:        "(No applicable commands in this context)",
 
 		Actions: Actions{
 			// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
diff --git a/schema/config.json b/schema/config.json
index cbb41866741..3a896b9b54e 100644
--- a/schema/config.json
+++ b/schema/config.json
@@ -63,6 +63,13 @@
           "type": "string",
           "description": "The key to trigger the command. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md"
         },
+        "commandMenu": {
+          "items": {
+            "$ref": "#/$defs/CustomCommand"
+          },
+          "type": "array",
+          "description": "Instead of defining a single custom command, create a menu of custom commands. Useful for grouping related commands together under a single keybinding, and for keeping them out of the global keybindings menu.\nWhen using this, all other fields except Key and Description are ignored and must be empty."
+        },
         "context": {
           "type": "string",
           "description": "The context in which to listen for the key. Valid values are: status, files, worktrees, localBranches, remotes, remoteBranches, tags, commits, reflogCommits, subCommits, commitFiles, stash, and global. Multiple contexts separated by comma are allowed; most useful for \"commits, subCommits\" or \"files, commitFiles\".",


Test patch:
diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go
index 1d842801966..c02353ea1c7 100644
--- a/pkg/config/user_config_validation_test.go
+++ b/pkg/config/user_config_validation_test.go
@@ -74,6 +74,58 @@ func TestUserConfigValidate_enums(t *testing.T) {
 				{value: "invalid_value", valid: false},
 			},
 		},
+		{
+			name: "Custom command sub menu",
+			setup: func(config *UserConfig, _ string) {
+				config.CustomCommands = []CustomCommand{
+					{
+						Key:         "X",
+						Description: "My Custom Commands",
+						CommandMenu: []CustomCommand{
+							{Key: "1", Command: "echo 'hello'", Context: "global"},
+						},
+					},
+				}
+			},
+			testCases: []testCase{
+				{value: "", valid: true},
+			},
+		},
+		{
+			name: "Custom command sub menu",
+			setup: func(config *UserConfig, _ string) {
+				config.CustomCommands = []CustomCommand{
+					{
+						Key:     "X",
+						Context: "global",
+						CommandMenu: []CustomCommand{
+							{Key: "1", Command: "echo 'hello'", Context: "global"},
+						},
+					},
+				}
+			},
+			testCases: []testCase{
+				{value: "", valid: false},
+			},
+		},
+		{
+			name: "Custom command sub menu",
+			setup: func(config *UserConfig, _ string) {
+				falseVal := false
+				config.CustomCommands = []CustomCommand{
+					{
+						Key:        "X",
+						Subprocess: &falseVal,
+						CommandMenu: []CustomCommand{
+							{Key: "1", Command: "echo 'hello'", Context: "global"},
+						},
+					},
+				}
+			},
+			testCases: []testCase{
+				{value: "", valid: false},
+			},
+		},
 	}
 
 	for _, s := range scenarios {
diff --git a/pkg/integration/tests/custom_commands/check_for_conflicts.go b/pkg/integration/tests/custom_commands/check_for_conflicts.go
index 024f32733ac..25f1d209761 100644
--- a/pkg/integration/tests/custom_commands/check_for_conflicts.go
+++ b/pkg/integration/tests/custom_commands/check_for_conflicts.go
@@ -19,7 +19,7 @@ var CheckForConflicts = NewIntegrationTest(NewIntegrationTestArgs{
 				Key:     "m",
 				Context: "localBranches",
 				Command: "git merge {{ .SelectedLocalBranch.Name | quote }}",
-				After: config.CustomCommandAfterHook{
+				After: &config.CustomCommandAfterHook{
 					CheckForConflicts: true,
 				},
 			},
diff --git a/pkg/integration/tests/custom_commands/custom_commands_submenu.go b/pkg/integration/tests/custom_commands/custom_commands_submenu.go
new file mode 100644
index 00000000000..45e1fb16991
--- /dev/null
+++ b/pkg/integration/tests/custom_commands/custom_commands_submenu.go
@@ -0,0 +1,75 @@
+package custom_commands
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var CustomCommandsSubmenu = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Using custom commands from a custom commands menu",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupRepo:    func(shell *Shell) {},
+	SetupConfig: func(cfg *config.AppConfig) {
+		cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
+			{
+				Key:         "x",
+				Description: "My Custom Commands",
+				CommandMenu: []config.CustomCommand{
+					{
+						Key:     "1",
+						Context: "global",
+						Command: "touch myfile-global",
+					},
+					{
+						Key:     "2",
+						Context: "files",
+						Command: "touch myfile-files",
+					},
+					{
+						Key:     "3",
+						Context: "commits",
+						Command: "touch myfile-commits",
+					},
+				},
+			},
+		}
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Files().
+			Focus().
+			IsEmpty().
+			Press("x").
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("My Custom Commands")).
+					Lines(
+						Contains("1 touch myfile-global"),
+						Contains("2 touch myfile-files"),
+					).
+					Select(Contains("touch myfile-files")).Confirm()
+			}).
+			Lines(
+				Contains("myfile-files"),
+			)
+
+		t.Views().Commits().
+			Focus().
+			Press("x").
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("My Custom Commands")).
+					Lines(
+						Contains("1 touch myfile-global"),
+						Contains("3 touch myfile-commits"),
+					)
+				t.GlobalPress("3")
+			})
+
+		t.Views().Files().
+			Lines(
+				Contains("myfile-commits"),
+				Contains("myfile-files"),
+			)
+	},
+})
diff --git a/pkg/integration/tests/custom_commands/global_context.go b/pkg/integration/tests/custom_commands/global_context.go
index 9f975d04cf7..82ef5301011 100644
--- a/pkg/integration/tests/custom_commands/global_context.go
+++ b/pkg/integration/tests/custom_commands/global_context.go
@@ -15,10 +15,9 @@ var GlobalContext = NewIntegrationTest(NewIntegrationTestArgs{
 	SetupConfig: func(cfg *config.AppConfig) {
 		cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
 			{
-				Key:        "X",
-				Context:    "global",
-				Command:    "touch myfile",
-				ShowOutput: false,
+				Key:     "X",
+				Context: "global",
+				Command: "touch myfile",
 			},
 		}
 	},
diff --git a/pkg/integration/tests/custom_commands/multiple_contexts.go b/pkg/integration/tests/custom_commands/multiple_contexts.go
index a5c982ee3a4..61d46775b00 100644
--- a/pkg/integration/tests/custom_commands/multiple_contexts.go
+++ b/pkg/integration/tests/custom_commands/multiple_contexts.go
@@ -15,10 +15,9 @@ var MultipleContexts = NewIntegrationTest(NewIntegrationTestArgs{
 	SetupConfig: func(cfg *config.AppConfig) {
 		cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
 			{
-				Key:        "X",
-				Context:    "commits, reflogCommits",
-				Command:    "touch myfile",
-				ShowOutput: false,
+				Key:     "X",
+				Context: "commits, reflogCommits",
+				Command: "touch myfile",
 			},
 		}
 	},
diff --git a/pkg/integration/tests/custom_commands/show_output_in_panel.go b/pkg/integration/tests/custom_commands/show_output_in_panel.go
index 171a477d853..7f00d8a5b1e 100644
--- a/pkg/integration/tests/custom_commands/show_output_in_panel.go
+++ b/pkg/integration/tests/custom_commands/show_output_in_panel.go
@@ -15,18 +15,19 @@ var ShowOutputInPanel = NewIntegrationTest(NewIntegrationTestArgs{
 		shell.EmptyCommit("my change")
 	},
 	SetupConfig: func(cfg *config.AppConfig) {
+		trueVal := true
 		cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
 			{
 				Key:        "X",
 				Context:    "commits",
 				Command:    "printf '%s' '{{ .SelectedLocalCommit.Name }}'",
-				ShowOutput: true,
+				ShowOutput: &trueVal,
 			},
 			{
 				Key:         "Y",
 				Context:     "commits",
 				Command:     "printf '%s' '{{ .SelectedLocalCommit.Name }}'",
-				ShowOutput:  true,
+				ShowOutput:  &trueVal,
 				OutputTitle: "Subject of commit {{ .SelectedLocalCommit.Hash }}",
 			},
 		}
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index 573daade1e7..69a9ed5ec3a 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -140,6 +140,7 @@ var tests = []*components.IntegrationTest{
 	custom_commands.AccessCommitProperties,
 	custom_commands.BasicCommand,
 	custom_commands.CheckForConflicts,
+	custom_commands.CustomCommandsSubmenu,
 	custom_commands.FormPrompts,
 	custom_commands.GlobalContext,
 	custom_commands.MenuFromCommand,