Please purchase the course to watch this video.

Full Course
Terminal user interfaces (TUIs) offer a richer and more interactive experience compared to standard command line interfaces by providing dynamic feedback, components, and layout options reminiscent of graphical applications. In Go, the Bubble Tea package by Charm is a leading tool for building robust TUIs, allowing developers to structure applications around a central state model and key methods for initialization, updating on events, and rendering output. Bubble Tea supports a variety of UI components—such as spinners, progress bars, and tables—through its modular "bubbles" library, while the companion "lip gloss" package enables stylish, themed output. Although Bubble Tea introduces its own architectural paradigm that may require adjustment, its flexibility makes it a powerful option for asynchronous and interactive terminal apps. Key implementation patterns include stateful models, event-driven updates, and view rendering, and developers can further extend functionality with advanced components and custom styling. For those seeking more streamlined integration with command-line tools, higher-level packages like HA, which leverage Bubble Tea under the hood, offer simplified approaches to building interactive forms and prompts.
No links available for this lesson.
In this lesson, we're going to take a look at a popular package in Go when it comes to building terminal user interfaces. A terminal user interface slightly differs from a command line interface, in that rather than using commands to perform actions like we've been doing throughout this course, a terminal user interface, however, bridges the gap between the CLI or the command line interface and something more like a user interface such as a web UI or even a standard GUI application.
The way it does this is by allowing you to have very dynamic feedback and interactions when it comes to the terminal interface. You can think of this as being similar to say the spinners that we saw in the previous module or that we implemented in the previous module as well as the progress bars as well. In this case, we were updating the interface whenever there was a change and it was giving the impression of a user or graphical user interface.
Introduction to Bubble Tea
The most popular package for creating terminal user interfaces in Go is Bubble Tea by Charm or Charm bracelet. If you're online at all, then you likely would have seen this being used or promoted by the actual team. They have a really good social media presence. Bubble Tea is a pretty fantastic package when it comes to being able to build terminal user interfaces.
As you can see that you can do many different kind of user interface approaches and they have their bubbles user interface components, which if we take a quick look at and scroll down, you can see has a number of different components for Bubble Tea applications, including:
- Loading spinners
- Text input
- Text areas
- Tables
- Progress bars
- Paginators
- Viewports
- etc, etc
Bubble Tea is very cool, but we're not going to touch on it much throughout this application. However, in the next lesson, we're going to be looking at how we can implement TUI forms in case you want to improve the way that your CLI CMS works by using a form as a terminal user interface in order to collect inputs rather than using it just through the command line interface.
Personally, I much prefer to use CLI's rather than TUI's, but it's very much a personal preference. And so we're going to take a look at how Bubble Tea works in case you want to build your own terminal user interfaces in the future.
How Bubble Tea Works
The way that Bubble Tea works is actually kind of simple. It's a very structured way of creating an application. Basically, you define what's known as a model, which is where you store your application's state. In this case, you can see the model being defined as choices, the cursor, so where the cursor is pointing, and which to-do items are selected.
You can then define your application's initial state. They do this using a function called initial model, which returns a model. You can think of this as being a constructor for your actual application model.
And then you have three methods to implement on this model in order for it to conform to the tea.Model
interface:
- The
Init
method, which is used to perform the initial IO or defining the initial IO state - The
Update
method is then used to handle any IO-based updates, so key events, timing events, etc, etc. And then is used to update the model's state in response to these events coming through - The
View
method, which is used to render out the current view or render out a text string based on the current application's model state, or the current models, or the model's current state
Example Implementation
So here, for example, I implemented a really simple bubble tea application before the lesson, which basically works by counting the, or performing ticks and then counting them up as it goes. So as you can see, counting zero, one, two, three, and it does so every one second.
This works by just defining the application state. So a count in this case, then defining a kind of doTick
method, which conforms, or which returns a tea.Cmd
. A tea.Cmd
is a function that will return a message when called. The function in this case, or the message in this case, is a tick message, which we're defining ourself. You can define anything as a tick message, or as a tea.Msg
. But the actual command itself performs a tick every one second and then calls the following callback.
So I'm using this in the init command to define the initial state. And then every time we receive a tick message in the update function, we then recall the doTick
, but we also increment the count. And then I'm also checking to see if the key message is q
, escape
, or ctrl+c
. Otherwise, I then call send the quit message.
Then for the actual view itself, which is where I'm rendering out the count, you can see counting. And then I actually just take the count from the current model and then render it out. We'll take a look at how we can actually do all of this. As you can see, it's pretty easy to be able to create some sort of asynchronous I/O application. And as you can see, the counter is counting up.
Building Our Own Application
In order to have a better understanding of how this works, however, we're going to take a look at implementing the spinner component that's provided by the bubbles library. So we'll just make use of a simple application that basically renders the spinners component that's already provided. However, as some homework, I'm going to set you a task of also implementing the progress spinner, which you can see here.
Creating the Model
In order to do so, as we saw in the documentation, we first need to define a model, which we can go ahead and create as a struct type. This is going to represent the application state that we currently have:
type Model struct {
// Application state will go here
}
In this case, our model needs to conform to three methods, which is the init method. So func
and we can do m Model
. We can define this as Init
. I'm going to go ahead and set this to be a capital. It's fine to have it as lowercase, but I think it just makes it a little clear that this is kind of a struct type, although lowercase will be fine in this case and properly preferred.
In any case, we first need to define the Init
method, which if we go ahead and take a look at, you can see is Init
and it returns a tea.Cmd
. We can do this by first calling tea
, which will import. Let's go ahead and actually wrap this a little bit. So tea
is an alias for the bubble tea package and we want to go ahead and return a tea.Cmd
. I think this is a pointer. No, it's not. There we go. And initially, we're just going to go ahead and return nil
, I believe:
func (m Model) Init() tea.Cmd {
return nil
}
Then we need to go ahead and have the update method added in. So Update
, which again, the update, I believe, returns two, which is a tea.Model
and a tea.Cmd
. So we want to go ahead and do a tea.Model
and tea.Cmd
. For this, we actually just want to return ourselves as the model. So we're not updating our own state. If you'll notice, we're not using pointer receivers. We're using non-pointer receivers. So in order to update, we return ourselves or we could return a brand new model if we want to. And we'll just go ahead and return nil
for the command:
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
As you can see, we currently do not conform to the model. That's because we're missing one last method, which is the View
. So in this case, the view itself, we want to return just a string. And this is what will be rendered out as follows. So we could just go ahead and return, say, "no model yet implemented":
func (m Model) View() string {
return "no model yet implemented"
}
One thing I forgot is that the update message actually receives a message or tea.Msg
as its parameter. So I need to go ahead and set that in tea.Msg
as follows.
Running the Program
Okay. So with our application or our TUI's application model defined, the next thing we need to do is to actually go ahead and run this as a tea program, which you can see here in the last step is to simply run our program, passing in the initial model to tea.NewProgram
and letting it rip.
We don't have the initial model function defined. Let's just go ahead and do this. For the meantime, we could call this. I'm going to go ahead and actually capitalize this InitialModel
, and this will just return a type of Model
. For the meantime, we don't have anything. So I can just go ahead and return an empty struct. We could just pass in an empty struct here as well. Either one's fine. In fact, now I'm going to leave this as this is, just because it's the way that the documentation specifies:
func InitialModel() Model {
return Model{}
}
Okay. So let's go ahead and define a new program, which is going to be a tea.NewProgram
. This takes a model. So we can go ahead and pass in the InitialModel
and that's it:
p := tea.NewProgram(InitialModel())
So then we can just go ahead and do if err := p.Run()
. This will run and we'll also go ahead and return a model. We don't need to do that. err != nil
. And we can just do a fmt.Fprintf
. We could do the log.Fatal
, but it's actually going to be easier to do os.Stderr
. "Failed to run". And then we'll just go ahead and print out the error as such. And we'll do an os.Exit
of one:
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to run: %v\n", err)
os.Exit(1)
}
Okay, great. Now, if I go ahead and run this as follows. "No model yet implemented". You can see that this is all that's happening, but it's taken control of standard input. So now I can't actually exit this application. You can see I'm typing. Nothing is happening. So I'm going to have to go ahead and actually close this window.
Adding Exit Functionality
And we need to go ahead and implement the ability to close this model or close this program whenever we receive a key of Q or control C or anything else. As you can see, this is where kind of Bubble Tea for me starts to become its own form of application. You can get it to work with Cobra, but because it's kind of a different paradigm for me, I feel like Bubble Tea is its entirely own application model.
In any case, let's go ahead and actually define a key entry so that we can exit the application whenever we press Q, control and C or anything else. To do so, we can achieve this using the update model, where we do a switch on the actual message type. And if it's a tea.KeyMsg
, we can go ahead and check to see what the key is and then call the quit. In fact, we can just go ahead and pretty much lift this up and use it inside. So let's go ahead and do so:
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
}
}
return m, nil
}
Again, we can do a key.Msg
. Let's go ahead and add in the semi braces or add in the curly braces here just so that we're not getting errors. Okay, so here we're doing a switch on the message or the msg.Type
. If it's a key message, then we're checking to see which key is actually pressed. And in this case, if it's control + C, Q, or we can even go ahead and do escape here as well, I think, then we'll go ahead and quit. So these will now cause the program to exit.
Okay, so if we go ahead and run this now, "no model yet implemented". If I press Q, this time we're exiting. And you also notice that the artifact is cleared up as well. If I go ahead and press escape, it also works. I think, yep, escape works. And control + C will work as well. There we go.
So now we're able to effectively quit. And we can see that this is actually the case by doing an fmt.Println
. We'll just go ahead and actually print out the msg.String()
. And if I go ahead and run this now, you can see the Q is there. It's kind of being overwritten. Control + C is there. And the escape is written as well. You can kind of see it being written at the front.
Adding a Spinner Component
Okay, so now we have our TUI application being deployed and ran. The next thing we want to do is to start adding some bubble components to it. If we take a look at bubbles itself, which is the component library, you'll see it provides, as we mentioned, both the spinner and the actual progress bar as well. So here's the spinner underneath the spinner package, and then they have progress underneath the progress package.
So let's go ahead and make use of these in order to add in both spinning and progress functionality into our TUI application. So we can simulate long running tasks taking place.
Defining the Spinner in Our Model
To do so, we need to define a spinner inside of our state, which we can do as follows. Defining a new property or new field inside of the model struct as spinner
, and then we can do this as spinner.Model
. This is another model provided by the spinner package, which almost conforms to the tea.Model
, but it's slightly different:
type Model struct {
spinner spinner.Model
}
In any case, inside of our actual InitialModel
function, we need to now initialize this spinner. So we can do so by creating a new spinner using the spinner.New
method, or we can do NewModel
. This is actually deprecated, so we're going to use the New
method, and then we can go ahead and actually set some properties of this:
func InitialModel() Model {
s := spinner.New()
s.Spinner = spinner.Dot
return Model{
spinner: s,
}
}
So we can do s.Spinner
, and we can go ahead and set this to be either the spinner.Dot
, which I think is the only one that you can choose from. You can also make your own, so this is just the default dot spinner. We saw that in the last module with all the different spinner frames you can use, and this just sets them to be dot frames.
Updating the Methods
Okay, so now that we have our spinner defined and we've set it to be dots, let's go ahead and actually add it to our model's struct as follows. And now that we have a spinner, we can actually go ahead and make use of it inside of both our update init and view functions.
To begin, let's go ahead and actually return the spinner as kind of a tick init. So we're telling the model itself that we want to move on to the next tick, or that the tick function is what we're going to be using for the initialization:
func (m Model) Init() tea.Cmd {
return m.spinner.Tick
}
We don't actually want to return the tick because this is a tea.Cmd
and the tea.Cmd
is a, I believe, a function that returns a tea.Msg
. So we're just going to go ahead and return the spinner.Tick
function instead. This means that the spinner.Tick
message will be called by the initial function itself.
Okay, next we then want to go ahead and actually return the spinner in the event we don't have any other messages coming through. In this case, we need to go ahead and modify our code just ever so slightly. So the first thing we need to do is add a default case into our kind of switch on the message string. So if we do get a key up and it's not any of the keys we're looking for, we can just go ahead and actually return m, nil
. This is very similar to what we're doing here. However, in this case, if we don't receive any messages or switch on the message type, then in this situation, we want to go ahead and return our new spinner model:
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
default:
return m, nil
}
default:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
}
So in order to do so, let's go ahead and call the m.spinner.Update
function. In this case, you can see we're causing the update function and we're returning either a spinner.Model
or a tea.Cmd
. So let's go ahead and capture this. We can do kind of s
for the model and cmd
in this case, and we're calling update and passing in the message to it.
Okay, now that we've called the update function, the next thing we need to do is to actually update our model's spinner, which you can do by doing m.spinner = s
. So we're updating our model spinner to the new updated spinner from the spinner component.
It starts to get a little bit weird to wrap your head around, but this is one of my problems with the Bubble Tea package. I think it's really great, but it does require you to think in the way that Bubble Tea works.
In any case, we want to return either our model that's been updated and we want to go ahead and return the next tea.Cmd
. Remember, a tea.Cmd
is just a function that basically will be called for the next update. So it's just the next update function that we want to use. In any case, we're returning both the new model with our updated spinner and the next tea.Cmd
.
Updating the View
And lastly, all we need to do is go ahead and update our view so that we return the spinner's view. So we can just go ahead and do m.spinner.View
, or we could even string interpolate this if we want to add something else. We'll take a look at that in a minute, however:
func (m Model) View() string {
return m.spinner.View()
}
Now, if we go ahead and run this, you can see we have a spinner working and it's just the dot spinner that we saw before, but it looks pretty great. We press q and we can get rid of it. So actually, let's go ahead and actually get rid of the printing out. If we go ahead and just run this, you can see we can press q and it now disappears. Very cool.
Adding Text to the Spinner
As I mentioned, we can interpolate this spinner view, however, with some additional text. So if we want to, we could go ahead and do fmt.Sprintf
. So we create a new string with some formatting in place. We use %s
for the actual spinner and then we can say "press q to stop" and we'll pass this in as an argument:
func (m Model) View() string {
return fmt.Sprintf("%s Press q to stop", m.spinner.View())
}
Now, if I go ahead and run this, you can see we're spinning and we can "press q to stop" and we now stop. Very cool.
Improving the Quit Behavior
That covers the basic implementation of a spinner when it comes to working with bubble tea. However, there are some more improvements we can actually go ahead and make to this. So we can go ahead and set in a property to the model calling isQuitting
, which I've spelt incorrectly isQuitting
. So we can go ahead and set a boolean to kind of tell our model that we are quitting in the event that we see a quit event from the keys:
type Model struct {
spinner spinner.Model
isQuitting bool
}
So we can go ahead and actually say m.isQuitting = true
and we're going to go ahead and actually return this back:
case "ctrl+c", "q", "esc":
m.isQuitting = true
return m, tea.Quit
Then this means we can actually go ahead and start controlling the way that our model works. We can actually make use of it in this view code so that we can keep the last line that was printed out. So let's say we want to go ahead and do capturing the string as follows and then we can do if m.isQuitting
and we'll just go ahead and return the string itself and we'll just go ahead and plus in a new line. Otherwise, we'll just return the string:
func (m Model) View() string {
str := fmt.Sprintf("%s Press q to stop", m.spinner.View())
if m.isQuitting {
return str + "\n"
}
return str
}
Now, if I go ahead and run this using the following command and we press Q to stop, you can see that the text still remains because as we're quitting, we added in a new line and then that that's previous new line gets flushed rather than flushing the last line we were on. So if you want to keep the text inputs of what your actual event is, you can then start to do other things when it comes to your view model.
Adding Styling with Lipgloss
In addition to being able to just create a simple spinner, we can also add effects or styling to that spinner as well, which is to make use of the component library of bubble tea called lipgloss. Lipgloss which allows you to basically style your bubble tea components and well do any sort of other styling as well. As you can see there's a bunch of styles you can apply and you can apply things like foreground, background, padding etc etc. It's actually very similar to say what we saw with the color package in the last lesson.
You can basically use it to specify all of these different colors etc etc. So let's go ahead and change the color of our spinner to be something different. We can do this by setting the style property and we can go ahead and do kind of lipgloss. So we'll import the lipgloss package in and we'll set a new style:
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
In this case we could go ahead and do foreground and we can go ahead and set this to be a terminal color. I think we can do lipgloss.Color
. Let's do red say. Not sure how to do a red color. Okay so we can use one of the ANSI colors. We can do lipgloss.Color
here. So lipgloss.Color
and we can go ahead and just pass in the color 9 I believe. It's actually a string. Not the most intuitive interface in my opinion but let's give this a go anyway and see what happens.
As you can see we now have a red spinner which is being set with different style. So yeah very cool.
Summary and Homework
When it comes to both Bubble Tea and all of its subsequent packages you can do an awful lot with it. However one thing to be considerate of is that it is much more low level when it comes to building out terminal user interfaces.
In order to get a better understanding however I'm going to set some homework in order to implement the progress bar. There is an example on how to implement in the bubbles framework. It's very similar to the spinner that we saw but it does require a little bit more work given the fact that you also have to handle viewport changes which is what we looked at in that previous lesson when we were handling the SIGWINCH
change.
Let's actually take a look at the animated example. As you can see there's a number of other different changes you need to handle. So being able to handle the window message size change which would allow you to redraw the actual progress bar and also handling a frame message as well which is part of the progress bar updates. So in this case you would go ahead and actually call the update method on the progress bar and of course when it comes to the view itself you would need to make use of the strings.Repeat
very similar to what we implemented in the last lesson.
That should give you a good understanding of how Bubble Tea works and how it can be somewhat complex. However in the next lesson we're going to take a look at another package by Charm bracelet that uses Bubble Tea under the hood but makes it a lot easier to integrate with our CLI application. This is going to be the huh package which allows us to build terminal forms and prompts without needing to worry about the underlying Bubble Tea models under the hood.
So once you've managed to complete that progress bar implementation I'll see you in the next lesson where we'll take a look at huh and and start adding in some interactive forms into our CLI application.
No homework tasks for this lesson.