DEV Community

Cover image for Nested Prompts in Go using promptui
Thwani sithole
Thwani sithole

Posted on

Nested Prompts in Go using promptui

I was working on a CLI tool written in Go, using the Cobra tool recently, and I had a use case where I needed a nested prompt for one of the commands. I was using promptui for the prompts and I couldn't find a straightforward way to do this. This short post will show how to create a nested prompt using promptui. The completed code can be found here.

We first need to create an empty Go project. We will call it nested-prompt:

$ mkdir nested-prompt && cd nested-prompt
$ go mod init github.com/Thwani47/nested-prompt 
Enter fullscreen mode Exit fullscreen mode

We'll then install the cobra, cobra-cli, and promptui packages:

$ go get -u github.com/spf13/cobra@latest
$ go install github.com/spf13/cobra-cli@latest 
$ go get -u github.com/manifoldco/promptui
Enter fullscreen mode Exit fullscreen mode

We can initialize a new CLI application using the cobra-cli and add a command to our CLI

$ cobra-cli init            # initializes a new CLI application
$ cobra-cli add config      # adds a new command to the CLI named 'config'
Enter fullscreen mode Exit fullscreen mode

We can clean up the cmd/config.go file and remove all the comments. It should be like this:

// cmd/config.go
package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

var configCmd = &cobra.Command{
    Use:   "config",
    Short: "Configure settings for the application",
    Long: `Configure settings for the application`,
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("config called")
    },
}

func init() {
    rootCmd.AddCommand(configCmd)
}

Enter fullscreen mode Exit fullscreen mode

We first need to create a custom type for our prompt. We do that by defining a promptItem struct as follows

type PromptType int

const (
    TextPrompt     PromptType = 0
    PasswordPrompt PromptType = 1
    SelectPrompt   PromptType = 2
)

type promptItem struct {
    ID            string
    Label         string
    Value         string
    SelectOptions []string
    promptType    PromptType
}
Enter fullscreen mode Exit fullscreen mode

The PromptType enum allows us to collect different types of input from our prompts, we can prompt the user for text, or sensitive values such as passwords or API Keys, or prompt the user to select from a list of defined values

We then define a promptInput function that will prompt for input from the user. The function returns the string value entered by the user or an error if the prompt fails.

func promptInput(item promptItem) (string, error) {
    prompt := promptui.Prompt{
        Label:       item.Label,
        HideEntered: true,
    }

    if item.promptType == PasswordPrompt {
        prompt.Mask = '*'
    }

    res, err := prompt.Run()

    if err != nil {
        fmt.Printf("Prompt failed %v\n", err)
        return "", err
    }

    return res, nil
}
Enter fullscreen mode Exit fullscreen mode

We then define a promptSelect function that will allow the user to select from a list of options. The function returns the string value selected by the user or an error if the prompt fails.

func promptSelect(item selectItem) (string, error) {
    prompt := promptui.Select{
        Label:        item.Label,
        Items:        item.SelectValues,
        HideSelected: true,
    }

    _, result, err := prompt.Run()

    if err != nil {
        fmt.Printf("Prompt failed %v\n", err)
        return "", err
    }

    return result, nil
}
Enter fullscreen mode Exit fullscreen mode

To simulate a nested prompt, we will create a promptNested function that will allow us to prompt the user for a value and the prompt will stay active until the user selects "Done". The function returns a boolean value that indicates that the prompt was a success.

The comments in the function explain what each major block of code is responsible for

func promptNested(promptLabel string, startingIndex int, items []*promptItem) bool {

    // Add a "Done" option to the prompt if it does not exist
    doneID := "Done"
    if len(items) > 0 && items[0].ID != doneID {
        items = append([]*promptItem{{ID: doneID, Label: "Done"}}, items...)
    }

    templates := &promptui.SelectTemplates{
        Label:    "{{ . }}?",
        Active:   "\U0001F336 {{ .Label | cyan }}",
        Inactive: "{{ .Label | cyan }}",
        Selected: "\U0001F336 {{ .Label | red  | cyan }}",
    }

    prompt := promptui.Select{
        Label:        promptLabel,
        Items:        items,
        Templates:    templates,
        Size:         3,
        HideSelected: true,
        CursorPos:    startingIndex, // Set the cursor to the last selected item
    }

    idx, _, err := prompt.Run()

    if err != nil {
        fmt.Printf("Error occurred when running prompt: %v\n", err)
        return false
    }

    selectedItem := items[idx]

    // if the user selects "Done", return true and exit from the function
    if selectedItem.ID == doneID {
        return true
    }

    var promptResponse string

    // if the prompt type is Text or Password, prompt the user for input
    if selectedItem.promptType == TextPrompt || selectedItem.promptType == PasswordPrompt {
        promptResponse, err = promptInput(*selectedItem)

        if err != nil {
            fmt.Printf("Error occurred when running prompt: %v\n", err)
            return false
        }

        items[idx].Value = promptResponse

    }

    // if the prompt type is Select, prompt the user to select from a list of options
    if selectedItem.promptType == SelectPrompt {
        promptResponse, err = promptSelect(*selectedItem)

        if err != nil {
            fmt.Printf("Error occurred when running prompt: %v\n", err)
            return false
        }
        items[idx].Value = promptResponse
    }

    if err != nil {
        fmt.Printf("Error occurred when running prompt: %v\n", err)
        return false
    }

    // recursively call the promptNested function to allow the user to select another option
    return promptNested(idx, items)
}
Enter fullscreen mode Exit fullscreen mode

Now we have all the methods we need and we need to test them out. Inside the Run function of the configCmd command, we will create a list of promptItem and call the promptNested function to prompt the user for input. The Run function should look like this:

// create a list of prompt items
items := []*promptItem{
    {
        ID:         "APIKey",
        Label:      "API Key",
        promptType: PasswordPrompt,
    },
    {
        ID:            "Theme",
        Label:         "Theme",
        promptType:    SelectPrompt,
        SelectOptions: []string{"Dark", "Light"},
    },
    {
        ID:            "Language",
        Label:         "Preferred Language",
        promptType:    SelectPrompt,
        SelectOptions: []string{"English", "Spanish", "French", "German", "Chinese", "Japanese"},
    },
}

// set the starting index to 0 to start at the first item in the list
promptNested("Configuration Items", 0, items)

for _, v := range items {
    fmt.Printf("Saving configuration (%s) with value (%s)...\n", v.ID, v.Value)
}
Enter fullscreen mode Exit fullscreen mode

Build and test the application as follows

$ go build . 
$ ./nested-prompt config
Enter fullscreen mode Exit fullscreen mode

The result is as follows
nested-prompt

Top comments (0)