Build Beautiful CLI's with Golang and Bubbletea
It's a running joke in tech circles that top tier engineers never leave the keyboard, UI's are for beginners. We at Quiet Storm don't believe this to be fact but it can be true that keyboard shortcuts, scripts, and CLI's can speed up a developers workflow. Actions that require a point and click could be completed in a fraction of the time using buttons on the keyboard. This is enhanced when the tasks is repetitive where the developer does it multiple times at any given moment.
An example would be clicking around in the AWS console. If an operator wanted to add objects to an S3 bucket and the only option was the AWS console. Clicking through dropdown menus, radio buttons, and input fields could take longer than deploying with a CLI or CloudFormation. Same for Kubernetes, a UI for your cluster with tools such as Rancher or the Kubernetes dashboard are helpful but it could be quicker for some to check deployed helm charts with a helm list -n my-namespace
command as opposed to clicking through menus again. For this reason we not only wanted to discuss CLI creation with Golang but with a really neat framework Bubbletea to enhance the task. It's brought to us by the awesome team over at Charm!
The tool is not mandatory for CLI tool creation with Go as the language comes packed with the capability to do so rather easy. Bubbletea takes that, and supercharges the terminal with a vast list of capabilities including colors, loading bars, and feature packed lists. A colleague of mines showed me this awsome-go repo of interesting Go libraries, spotted Bubbletea and have been learning the library since.
Prerequisites
To complete this tutorial, you'll need the following:
- Golang installed (used 1.22)
- Internet connection to download the external libraries
- A basic understanding of Golang
Overview
The Charm team has excellent documentation on their tool with informative tutorials to get you started. The purpose of this post is to explain these concept from a different voice. Always refer to their documention to dig deeper or for clarification.
If you've ever written code using Javascript/React, you'll feel right at home with the framework. It utilizies the Elm Architecture of a model, view, and controller.
(Graphic here)
- Model: the current state of the application or in other words, what should be displayed to the user
- View: what the user sees, how the data defined in the model is shown to the user
- Controller: a way to update the model, when the user interacts with our view it updates the model
These core concepts work in a loop. When the user interacts with a controller element such as a button press or mouse click, this triggers a change in the model. When the model changes the view should pick up on the change and now display what is currently in the model. For this tutorial we're going to build a simple counter and title. The controllers will be the up and down arrows, our view is the terminal and the model will start at the number 0 counting up and down from there.
Let's get started!
Clone the repository here to follow along.
Model
First thing we want to do is define our model. Here we're saying "Ok, what data do I want to show the user"? It's basically the functionality of the function, what the user needs to achieve with your CLI tool. Below is a snippet that does a few things with the model:
1// Define the model
2type model struct {
3 counter int
4 title string
5}
6
7// Give the model an initial value
8func initialModel() model {
9 return model{
10 counter: 0,
11 title: "Quiet Storm Counter",
12 }
13}
14
- Lines 2-4: We're defining the structure of the model. The model we're defining will have a counter and title. Our counter is an integer and will increment/decrement by 1. Title is just a string to hold the title of our counter
- Lines 8-13: Here we're giving our model initialValues, with the counter starting at 0, and the title of our counter being Quiet Storm Counter. Change it to whatever you want your counter to be titled and you can have your counter at a different integer. Why not start at 100!
Init / tea.Cmd
The Init() function is used when the Bubbletea program start. This could be something like an HTTP request, starting a background process or loading configurations. If you need something to happen at the start of your CLI tool do it here.
// Init command to run when the program starts
func (m model) Init() tea.Cmd {
return nil
}
We don't need to make any intial actions so we'll just return nil. Notice that the function returns a tea.Cmd. A Cmd (command) is a function for asynchornous actions and return a tea.Msg (message). We then use the data returned in a Msg in our Update function (discussed in further details later).
For example if we wanted to get some user input, we would define a function that returns a Msg of the input, then in the Update function when we check for messages, you'll pass the data here. Below is an example:
// Message to hold user input
type inputMsg string
// Command to read user input
func readInputCmd() tea.Cmd {
return func() tea.Msg {
var input string
fmt.Scanln(&input)
return inputMsg(input)
}
}
// Init function when the program starts
func (m model) Init() tea.Cmd {
return readInputCmd()
}
Since the command is triggered in the Init function, we'd check for the inputMsg that readInputCmd returns in the Update function we'll talk about next.
Update
We've now defined what our model should look like and a nil Init since we don't need anything to happen in the beginning. Let's write how the program should update model based on the earlier mentioned Msg.
1func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2 switch msg := msg.(type) {
3 case tea.KeyMsg:
4 switch msg.String() {
5 case "q", "ctrl+c":
6 clearScreen()
7 return m, tea.Quit
8 case "up", "k":
9 m.counter++
10 case "down", "j":
11 m.counter--
12 }
13 }
14 return m, nil
15}
In the code snippet our Update function takes a message as a parameter and should return a model and a command. Think back to how Bubbletea is crafted with the Elm architecture in mind. If we make the connection, after a message comes in such as certain button pressed in the snippet (tea.KeyMsg), Update() should return a new model for View() to display.
- Line 2: the first switch is defined checking for a type of message
- Line 3: handles a situation when the message is a keyboard button
- Lines 4-14: the second switch here checks for the certain combination or certain button that was pressed. If its a "q" or "ctrl+c" our program quits but first clears the screen. If its the up arrow or "k", increment the counter defined in our model by one, if down or "j" take the counter down one, and finally on line 14 return the new model and a nil command.
Since the counter in our model has changed, our View will pick that up and regenerate what's shown to the user. Lets discuss that function now.
View
Now we're getting into the user interface, what you should see in the terminal. It's not as complicated as you would think, its all defined as a simple string.
1var (
2 // Bright orange color for counter
3 counterStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500"))
4 // Style for the title
5 titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF"))
6)
7
8func (m model) View() string {
9 title := titleStyle.Render(m.title)
10 counter := counterStyle.Render(fmt.Sprintf("Count: %d", m.counter))
11 return fmt.Sprintf("%s\n\n%s", title, counter)
12}
- Lines 1-6: here we're defining some styling with another package from Charm called Lipgloss. We're just setting a color for our text but you can do many things with the package, explore it more in their repo.
- Lines 9-11: we're attaching our styles to the model objects. Once that complete return the string object passed to the fmt.Sprintf for formatting. This code could be cleaned up but this is how it came out from my brain :)
Again, note that the return value is just a string so Views are pretty straight forward.
main()
Finally we bring everything together and run the program!
1func main() {
2 p := tea.NewProgram(initialModel())
3 if _, err := p.Run(); err != nil {
4 fmt.Printf("Something went wrong: %s", err)
5 }
6}
- Line 2: initialize a new tea program and pass it our initialModel
- 3-4: we're using the Run function on our NewProgram to do exactly what it says, run the program and if it returns an err, print out "Something went wrong" and the error message. If everything looks good, run the program.
If you've made it this far and you haven't run the program already, run the following from the root of the cloned repo:
- go get .
- Compile with: go build -o myprogram main.go
- ./myprogram
- Remember to press 'q' to quit the program
You should see something like this:
Now you have a colorful basic CLI counter tool.
Notice the GIF above, it's nice right? This was created with yet another Charm project VHS. Check it out to make really clean GIFs for demos of your CLI tools.