Enter TUI when subcommand has no args

Amolith created

When a wrapped command is invoked with no flags or arguments (e.g. "keld
restore"), enter the interactive TUI session with the command
pre-selected, skipping the menu screen. Commands with arguments continue
through the non-interactive path as before.

runInteractive now accepts a preselectedCommand parameter. When
non-empty, the menu screen is omitted and the pre-selected command is
used throughout the session.

Change summary

cmd/root.go          | 29 +++++++++++++++++-----
cmd/root_test.go     |  3 ++
cmd/subcommands.go   | 18 ++++++++++++++
cmd/wrapspec.go      | 12 ++++++++
cmd/wrapspec_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 113 insertions(+), 8 deletions(-)

Detailed changes

cmd/root.go 🔗

@@ -45,7 +45,7 @@ var rootCmd = &cobra.Command{
 	TraverseChildren: true,
 
 	RunE: func(cmd *cobra.Command, _ []string) error {
-		commandName, preset, overrides, err := runInteractive()
+		commandName, preset, overrides, err := runInteractive("")
 		if err != nil {
 			return err
 		}
@@ -85,7 +85,11 @@ func runCommand(commandName string, cmd *cobra.Command, rawArgs []string, sessio
 	}
 
 	preset := flagPreset
-	interactive := cmd == cmd.Root()
+	// Interactive mode when launched from root command or when a
+	// TUI session ran (presetResolved). The session handles preset
+	// selection, but runCommand may still need to collect values
+	// for commands without dedicated TUI screens (e.g. backup paths).
+	interactive := cmd == cmd.Root() || presetResolved
 
 	if preset != "" {
 		if err := validatePreset(preset); err != nil {
@@ -219,13 +223,18 @@ func isFlagToken(arg string) bool {
 // menu, preset selector (if needed), and command-specific screens
 // (e.g. the restore flow). Returns the chosen command, preset, any
 // collected overrides, or ("", "", nil, nil) if the user cancelled.
-func runInteractive() (command, preset string, overrides map[string][]string, err error) {
+func runInteractive(preselectedCommand string) (command, preset string, overrides map[string][]string, err error) {
 	styles := theme.New(true)
 
 	var screenList []ui.Screen
 
-	menuScreen := screens.NewMenu(menuItems, &styles)
-	screenList = append(screenList, menuScreen)
+	// When a command is pre-selected (e.g. "keld restore"), skip the
+	// menu entirely. Otherwise show the interactive command menu.
+	var menuScreen *screens.Menu
+	if preselectedCommand == "" {
+		menuScreen = screens.NewMenu(menuItems, &styles)
+		screenList = append(screenList, menuScreen)
+	}
 
 	// Build the preset screen only when needed:
 	// - --preset on CLI: skip (already resolved).
@@ -251,7 +260,10 @@ func runInteractive() (command, preset string, overrides map[string][]string, er
 	// screens. It resolves the config and dynamically builds the
 	// remaining screens via ExtendMsg.
 	resolveScreen := screens.NewResolve(func() []ui.Screen {
-		cmd := menuScreen.Selection()
+		cmd := preselectedCommand
+		if menuScreen != nil {
+			cmd = menuScreen.Selection()
+		}
 		p := preset
 		if presetScreen != nil {
 			p = presetScreen.Value()
@@ -296,7 +308,10 @@ func runInteractive() (command, preset string, overrides map[string][]string, er
 		return "", "", nil, nil
 	}
 
-	command = menuScreen.Selection()
+	command = preselectedCommand
+	if menuScreen != nil {
+		command = menuScreen.Selection()
+	}
 	if !slices.Contains(knownCommands, command) {
 		return "", "", nil, fmt.Errorf("unknown menu command %q", command)
 	}

cmd/root_test.go 🔗

@@ -199,13 +199,16 @@ func setRootFlagValuesForTest(t *testing.T, preset string, showCommand bool, con
 	t.Helper()
 
 	prevPreset, prevShowCommand, prevConfigFile := flagPreset, flagShowCmd, flagConfigFile
+	prevPresetResolved := presetResolved
 	flagPreset = preset
 	flagShowCmd = showCommand
 	flagConfigFile = configFile
+	presetResolved = false
 	t.Cleanup(func() {
 		flagPreset = prevPreset
 		flagShowCmd = prevShowCommand
 		flagConfigFile = prevConfigFile
+		presetResolved = prevPresetResolved
 	})
 }
 

cmd/subcommands.go 🔗

@@ -44,6 +44,24 @@ func registerSubcommands() {
 				if wantsCommandHelp(args) {
 					return cmd.Help()
 				}
+
+				// When invoked with no flags or arguments and the command
+				// is wrapped (has TUI screens), enter the interactive
+				// session with the command pre-selected, skipping the
+				// menu screen.
+				if len(args) == 0 && isWrappedCommand(commandName) {
+					cmdName, preset, overrides, err := runInteractive(commandName)
+					if err != nil {
+						return err
+					}
+					if cmdName == "" {
+						return nil
+					}
+					flagPreset = preset
+					presetResolved = true
+					return runCommand(cmdName, cmd, nil, overrides)
+				}
+
 				return runCommand(commandName, cmd, args, nil)
 			},
 		}

cmd/wrapspec.go 🔗

@@ -1,6 +1,10 @@
 package cmd
 
-import "git.secluded.site/keld/internal/ui/screens"
+import (
+	"slices"
+
+	"git.secluded.site/keld/internal/ui/screens"
+)
 
 // wrappedCommand describes a restic command that keld exposes in its
 // interactive menu and shell completions.
@@ -39,6 +43,12 @@ var knownCommands = func() []string {
 	return names
 }()
 
+// isWrappedCommand reports whether the given command name is one of
+// the commands keld actively wraps with TUI screens.
+func isWrappedCommand(name string) bool {
+	return slices.Contains(knownCommands, name)
+}
+
 // flagContract describes a restic flag that keld semantically depends on
 // (e.g. used in HasFlag checks or keyAliases).
 type flagContract struct {

cmd/wrapspec_test.go 🔗

@@ -0,0 +1,59 @@
+package cmd
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestIsWrappedCommand(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		cmd  string
+		want bool
+	}{
+		{name: "backup is wrapped", cmd: "backup", want: true},
+		{name: "restore is wrapped", cmd: "restore", want: true},
+		{name: "snapshots is wrapped", cmd: "snapshots", want: true},
+		{name: "cat is not wrapped", cmd: "cat", want: false},
+		{name: "ls is not wrapped", cmd: "ls", want: false},
+		{name: "empty string", cmd: "", want: false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			got := isWrappedCommand(tt.cmd)
+			if got != tt.want {
+				t.Errorf("isWrappedCommand(%q) = %v, want %v", tt.cmd, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestSubcommandWithArgsStaysNonInteractive(t *testing.T) {
+	// Not parallel: mutates package-level flag vars via
+	// setRootFlagValuesForTest and environment via t.Setenv.
+	configFile := setupCommandConfig(t)
+	setRootFlagValuesForTest(t, "home@cloud", true, configFile)
+	t.Setenv("KELD_CONFIG_FILE", "")
+	t.Setenv("KELD_DRYRUN", "")
+
+	backup := lookupSubcommand(t, "backup")
+	out, err := captureStdout(t, func() error {
+		return backup.RunE(backup, []string{"--tag", "daily", "/src"})
+	})
+	if err != nil {
+		t.Fatalf("subcommand with args returned error: %v", err)
+	}
+
+	// Should produce dry-run output containing the command.
+	if !strings.Contains(out, `"backup"`) {
+		t.Fatalf("expected dry-run output to contain backup command, got:\n%s", out)
+	}
+	if !strings.Contains(out, `"--tag" "daily"`) {
+		t.Fatalf("expected dry-run output to contain --tag daily, got:\n%s", out)
+	}
+}