aboutusesTILblog
go10.03.2024 11 min read

Making a TUI with Go

A reflection on my experience making a TUI with Go

A bubbletea sitting on top of a laptop

You know the drill; start with why! If you don’t know of Simon Sinek’s TED talk, I highly recommend it. It’s a great talk that has helped me in many aspects of my life. So, let’s start with why I decided to make a terminal user interface (TUI) with Go.

The primary reason I made this TUI was because it aligned with my goals for the year. If you read my previous blog post, you know that I’m devoting 2024 to learning Go and trying my best to channel my inner Gopher. Moreover, I wanted to create a project that would be not only helpful to me but also to my colleagues. Since I’m an avid neovim user, I’m fortunate to be able to use ChatGPT.nvim while I’m working on features. However, my colleagues who use VSCode don’t have the same luxury (yeah yeah, I know they have copilot, but the neovim plugin just hits different). Beyond those reasons, the final (some would say superficial) reason is that I wanted to make a TUI because I thought it would be fun. AI is the latest buzzword and I wanted to be part of the hype train… so let’s go 🚄.

What Did I Make?

I made a TUI that allows you to interact with OpenAI’s ChatGPT model. The TUI is written in Go and is therefore blazingly fast (cringed as I typed that 😅). The TUI is a simple interface (because, it’s Go, we gotta embrace simplicity) that allows you to interact with the your chosen model (it defaults to chatgpt-3.5-turbo).

A demo shot of the tui in action

If you want to try it out yourself, you can find the source code on GitHub! Now, if you’re wondering what did I use to make this work of art (awh shucks, thanks friend), fret not friend, I’ll tell you (I mean, you can just check out the repo, but I’ll tell you here as well 😆).

The Fun Parts

Before I jump into the things that I struggled with (and ultimately learned a lot about) while developing this application, I want to cover the things that got me going as I was chugging along with this tui.

The Elm Architecture

First and foremost, I was thrilled to learn more about The Elm Architecture. For those of you who aren’t familiar, Elm is a functional programming language that compiles to JavaScript — wild right. The Elm Architecture is a pattern that is used to build web applications in Elm (duh). In short, there are three parts to the Elm Architecture: Model, View, and Update. The Model is the state of the application, the View is the way to turn your state into the UI, and the Update is way to update your state based on messages. You can check out this simple example using the Elm Architecture in Go on Github ;it writes what the user just typed into a debug.log file.

Coming from React, it was tough to wrap my head around this architecture at first, but as I worked my way through this project, I started to see the beauty. Simple, yet elegant. Huge shout out to the one homie on youtube that made a video on the Elm Architecture in Go; you can watch it below.

It wasn’t enough to consume the video that I watched above; I had to try and reason about what was going on in the code. So, before I even wrote any code, I created the below mermaid diagram to help me understand how exactly I would go about creating the TUI. Now I want to be clear, I iterated on the below diagram quite a bit and it’s the basic understanding that I arrived at after watching the video and playing around with some bubbletea examples. Don’t @ me if it’s not perfect 😅.

Bubble Tea
Event Loop______
Bubble Tea Init
Cmd
Bubble Tea Model
Bubble Tea Update
Bubble Tea View
Msg
Side Effect -- keyboard stuff
package main
Bubble Tea Program

This type of architecture was fun to wrestle with and I’m glad I did (I freaking love diagramming)! One of the most fun things for me was trying to figure out how to handle concurrency in Go. I’m coming from Browser and Node land, so I’m used to async and await, an event loop with several types of queues, and a call stack. Go’s concurrency model is different, but it’s not too difficult to understand; its simpler than other models I have experience with, such as the ones you might come accross in traditional C programs.

My understanding of concurrency in Go can be summarized as the following:

Don’t communicate by sharing memory; share memory by communicating

Rather than trying to use locks and mutexes to control access to shared memory, Go encourages you to use channels to communicate between goroutines. In fact, bubbletea uses its own scheduler to handle goroutines that are dispatched by Commands; it’s kind of an anti-pattern to use go routines in a bubbletea program. If you want to learn the basic rules of thumbs when it comes to Commands in BubbleTea, check out this blog post

So yeah, I didn’t directly write any goroutines in my program, but I did have to reason about how to handle concurrency in my program, specifically when I was consuming the streamed response from OpenAI’s API. I had to figure out how to handle the stream of responses from OpenAI’s API and update the state of my application accordingly. I ended up assigning my own ID’s to each message that I received from the stream and then used those ID’s to sort the messages that were coming in.

func (m *Model) appendAndOrderProcessResults(msg ProcessResult) {
	// log.Println("Appending and ordering process results", msg)
	m.ArrayOfProcessResult = append(m.ArrayOfProcessResult, msg)
	m.CurrentAnswer = ""

	// we need to sort on ID here because go routines are done in different threads
	// and the order in which our channel receives messages is not guaranteed.
	// TODO: look into a better way to insert (can I Insert in order)
	sort.SliceStable(m.ArrayOfProcessResult, func(i, j int) bool {
		return m.ArrayOfProcessResult[i].ID < m.ArrayOfProcessResult[j].ID
	})
}

If you’ve got a better solution in your brain, hit me up; I’d love to hear it!

Publishing Something to the World (That Isn’t a Todo App)

Let’s be honest (well I’ll be honest), the first app most people make when they are learning a new language or framework is a TODO app. I’m TODO’d out guys and unlike Ryan Florence, I don’t think I have it in me right now to make Trellix 😆. Plus, I just made a TODO app with lit and htmx (you can read about it here 😅).

I was excited to make something that could live on homebrew and be used by my colleagues! Novelty is exciting and publishing something to homebrew is as novel as it comes for me (well at least it was at the time of writing this blog post). Not going to lie, I felt like a real nerd boss when I saw my TUI on homebrew 😎 (well at least as a tap).

Variety is the spice of life

The Things I Struggled with

Struggling was fun so I’m not going to say the “not so fun parts”

Event Propagation and Delegation

I’m so used to not having to think about event propagation and delegation when programming because, thankfully, I have my buddy the browser to handle that for me. Having to manually handle event propagation and delegation was a bit of a struggle for me; not because it’s hard, but because I failed to remember to do it at first 😅. For example, I have a ProcessResult message in my program that I use to handle the messages that I receive from OpenAI’s API. I spent way too long trying to figure out why my event wasn’t being propagated down to the Update function of my message-stream… turns out I straight up forgot to propagate the event down to the sessions model😅.

Error Handling

So Go encourages you to return two values from your functions: the first one being your actual value and the second value being an error. This is a great pattern because it forces you to handle your errors. However, if you choose to
ignore this convention and panic everywhere… you’re gonna have a bad time.

I eventually realized the errors of my ways and started to return and handle my errors appropriately. Despite what people say about Go not having error handling, I found the whole multiple returns thing and returning an error to be quite nice. It’s a bit verbose, but it’s clear and it’s easy to reason about.

via GIPHY

If you don’t wanna have a bad time, handle your errors 😅.

Concurrency and the Lack of Content

Alright, so I mentioned above that I had to figure out how to handle the stream of responses from OpenAI’s API and update the state of my application accordingly. However, I was not prepared for the sheer lack of content available on the interweb when it came to this subject matter. I’m privelleged on the web to be able to find a blog post or a video on just about anything I want to learn about, but when it came to bubbletea and handling things like streams of data in Go, it was kinda tough to find content. I had to rely on the bubbletea examples, the bubbletea documentation, and trial and error to figure out how properly author my program.

Publishing

Okay, I did mention above that I was super excited to publish my TUI to homebrew, but that doesn’t mean it wasn’t a struggle! I learned during my publishing attempts via github actions that my usage of sqlite relied on CGO (which is using C in your Go code). For those of you who don’t know, sqlite is a database engine writte in C… so I had to figure out how to get CGO to work with gorelaser (the tool I used to publish my TUI to homebrew).

I eventually figured out that I needed to use goreleaser-cross and set the CGO_ENABLED environment variable to 1 in my .goreleaser.yml file. I also eliminated the binaries for linux and windows because, well, I didn’t want to have to support them 😅.

I don’t have a full grasp on what is going on in my Makefile yet, but I’ve got enough of a grasp to know how to publish releases and how to create release candidates.

Testing

I’m not going to lie, I didn’t write a single test for this project. It’s on my list of things to learn.

Closing Thoughts

I had a lot of fun making this TUI and I learned a lot about Go, bubbletea, and OpenAI’s API. I learned a lot from the struggle (like anything in life) and the end result, while not perfect, is good enough. After all, what’s right is what works. Am I right? 😆

I’m excited to continue to learn Go and I’m excited to make more applications in the future! Was what I wrote helpful? Did you learn something interesting? If you did, drop me a line; I’d love to hear from you!