Bubble Tea

Feb 17, 2023 · 8 min read

My software development work is mostly done in a terminal. So I suppose it is natural that most of my tools are using a command-line interface. Besides writing bash scripts, there are situations where I need to write CLI programs myself from time to time. In my day job of writing enterprise applications, I almost use C# and JavaScript exclusively. However, when it comes to writing CLI tools, Go is the language I preferred. There are many CLI frameworks written in Go but cobra is the one I used most. It is also a framework used by many tools like kubectl.

cobra itself is a very powerful framework and, combining it with viper and pflag, it has pretty much all you need in terms of writing a command line program.

Using arguments or flags with a CLI program makes it easy to automate things. However, there are times that I will need to handle more complicated interactions between users and the program. These interactions can be done via a TUI but cobra does not have any coverage on it. As you could probably guess by now, Bubble Tea is the tool which can be integrated into cobra to allow building a TUI with such interactions.

Bubble Tea is a TUI framework from charm.sh and it does not contain any UI components. It can be used without UI components and developer will have to write the code responsible for rendering and behaviour. Luckily, charm.sh as also provides a set of common UI components in repository Bubbles and it can be used in Bubble Tea programs.

CLI programs which I built with bubbletea usually start with cobra as not all workflows require a TUI (such as my-app login). Some of the commands requires a TUI and that is when code will be written to start a bubbletea program.

An example on command login with cobra.

func runLogin(_ *cobra.Command, _ []string) error {
	token, err := authhelper.GetToken()
	if err != nil {
		return err
	}
	fmt.Println("Login has been completed successfully")
	return nil
}

An example on command list (which is interactive) with cobra.

func runList(_ *cobra.Command, _ []string) error {
  config, err := getOAuthConfigurationFromViper()
  if err != nil {
    return err
  }
  client, err := api.NewClient(config.GetOAuthConfig())
  if err != nil {
    return err
  }
  items, err := client.ListItems()
  if err != nil {
    return err
  }

  columns := getItemColumns()
  rows := getRows(items)

  t := table.New(
    table.WithColumns(columns),
    table.WithRows(rows),
    table.WithFocused(true),
    table.WithHeight(displayRowCount),
  )

  t.SetStyles(getStyles())

  m := tableModel{
    apiClient: client,
    table:     t,
    data:      items,
    tableKeys: tableEventKeys,
    inputKeys: inputEventKeys,
    help:      help.New(),
  }

  if err := bubbleteahelper.SetupLogFile(logFilePath, logFilePrefix); err != nil {
    return err
  }

  if _, err := tea.NewProgram(m).Run(); err != nil {
    return err
  }
  return nil
}

As you can see in the example with command list, although there is a bit setup work to be done before starting bubbletea, it is no very involved and relatively easy. The above example retrieves data from an API and populate it into a table. We setup a table with column headers, data (items), number of rows to be displayed, key bindings (tableKeys and inputKeys). We also setup of bubbletea with a log file as we cannot print log lines into a terminal since bubbletea takes over the whole terminal screen most of the time. tea.NewProgram(m).Run() returns the final model (which contains states) being used in bubbletea but we are ignoring it in this example.

One of the nice things with bubbletea is that the architecture (or program flow) can be easily understood. It uses The Elm Architecture. The architecture is mostly used to generate HTML renderings and the data flow is shown below.

The Elm Architecture

(illustration taken from dennisreimann.de)

To start a program tea.NewProgram(m), developer will have to supply a model and the model m implements the following interface.

type Model interface {
	// Init is the first function that will be called. It returns an optional
	// initial command. To not perform an initial command return nil.
	Init() Cmd

	// Update is called when a message is received. Use it to inspect messages
	// and, in response, update the model and/or send a command.
	Update(Msg) (Model, Cmd)

	// View renders the program's UI, which is just a string. The view is
	// rendered after every Update.
	View() string
}

We can ignore Init() and focus on Update() and View(). Update() processes input Msg from bubbletea framework (or “Elm Runtime” in the illustration). Msg is likely an event (such as a key event, a window resize event or a custom event representing a business event). Update() contains the logic to process Msg and determines the model Model and command Cmd to be returned. Model responsible for all things about a UI including states and behaviour. Therefore, Model returned from Update() represents the next UI to be presented to the user while Cmd is a function returning a Msg for the next UI (Model) to be processed in its Update(). Update() has access to Model and, thus, it is also responsible to update states in Model. View() will be invoked after Update() and it is responsible for drawing (or writing characters) to the terminal to create UI required.

An example on Model and its functions.

type dateInputModel struct {
	fieldDescription string
	itemName         string
	input            textinput.Model
	originalText     string
	err              error
	help             help.Model
	keys             inputKeyMap
	parent           tea.Model
}

func (m dateInputModel) Init() tea.Cmd { return nil }

// Update updates the states of the date input
func (m dateInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	var cmd tea.Cmd
	var cmds []tea.Cmd

	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		m.input.SetValue(m.originalText)
		m.input.Focus()
	case tea.KeyMsg:
		switch {
		case key.Matches(msg, m.keys.SaveAndExit):
			cmds = append(cmds, m.patchItem())
		case key.Matches(msg, m.keys.Cancel):
			return m.parent, showTable(nil)
		}
	case recordUpdated:
		return m.parent, showTable(msg.records)
	case errored:
		m.err = msg.err
	}

	m.input, cmd = m.input.Update(msg)
	cmds = append(cmds, cmd)
	return m, tea.Batch(cmds...)
}

// View renders the date input view
func (m dateInputModel) View() string {
	title := fmt.Sprintf("%s of '%s'", m.fieldDescription, m.itemName)
	errorMessage := ""
	if m.err != nil {
		errorMessage = m.err.Error()
	}

	return fmt.Sprintf(
		"%s\n\n%s\n\n%s%s\n",
		title,
		m.input.View(),
		getErrorMessageSection(errorMessage),
		m.help.View(m.keys),
	)
}

As you may be aware dateInputModel from the example above, a Model can contain multiple bubbles UI components (textinput.Model and help.Model in the example above) to help with rendering UI in View().

In Update() in the example above, besides it processes business messages recordUpdated and errored, it also processes key presses via tea.KeyMsg. As you can see in the switch statement followed, we can define keys to be handled in the UI. The following example shows how keys can be defined.

import "github.com/charmbracelet/bubbles/key"

func (k inputKeyMap) ShortHelp() []key.Binding {
	return []key.Binding{k.SaveAndExit, k.Cancel}
}

func (k inputKeyMap) FullHelp() [][]key.Binding {
	return [][]key.Binding{k.ShortHelp()}
}

var inputEventKeys = inputKeyMap{
	SaveAndExit: key.NewBinding(
		key.WithKeys("ctrl+s"),
		key.WithHelp("ctrl+s", "save and exit"),
	),
	Cancel: key.NewBinding(
		key.WithKeys("esc", "ctrl+c"),
		key.WithHelp("esc/ctrl+c", "cancel"),
	),
}

type inputKeyMap struct {
	SaveAndExit key.Binding
	Cancel      key.Binding
}

Package key from bubbles provides functionalities required to define keys. This allows naming of key bindings and assigning multiple keys to the same binding. It is also a nice feature that this allows defining help message for a binding.

Table is one of the most useful UI components is dealing with data. It is easy to setup table by calling its SetRows() with []Row where Row is just []string. We can also have key bindings previous example as show below.

example on table

With key bindings, we can provide actions to a table row and it makes interactions with or changes to a row possible. If you want, it is also possible to configure a table to allow multiple row selection which allows bulk action on selected rows. Table has a viewport included and that means height could be set to a table and, if not all rows of data fit into the table, scrolling will be enabled and it is a very nice UX.

List is an UI component handling a number of items well. Each item can have its name and description.

example on list

Filtering on items before selection/de-selection is also possible. Each item in a list has to satisfy Item interface includes FilterValue() returning a string so that the list can use that value for filtering.

type Item interface {
	// Filter value is the value we use when filtering against this item when
	// we're filtering the list.
	FilterValue() string
}
// an implementation example
func (i listItem) FilterValue() string { return i.title }

Toggling of an item in a multi-select list can be done by adding a DefaultDelegate. (The following example does not allow toggling when multi-select is not enabled.)

type listSelectModel struct {
	fieldDescription   string
	originalSelections []string
	selections         []string
	multiSelect        bool
	itemName           string
	list               list.Model
	help               help.Model
	itemKeys           listMultiItemKeyMap
}

type listMultiItemKeyMap struct {
	ToggleSelection key.Binding
}

func newListSelectModel(fieldDescription string, originalSelections []string, selections []string, multiSelect bool, item string) listSelectModel {
	delegate := newListItemDelegate(listMultiItemEventKeys, multiSelect)
	newList := list.New(getListItems(selections, originalSelections), delegate, 0, 0)
	newList.Title = fmt.Sprintf("%s of '%s'", fieldDescription, item)
	return listSelectModel{
		fieldDescription:   fieldDescription,
		originalSelections: originalSelections,
		selections:         selections,
		multiSelect:        multiSelect,
		itemName:           item,
		list:               newList,
		help:               help.New(),
		itemKeys:           listMultiItemEventKeys,
	}
}

func newListItemDelegate(keys listMultiItemKeyMap, multiSelect bool) list.DefaultDelegate {
	d := list.NewDefaultDelegate()

	if !multiSelect {
		return d
	}

	d.UpdateFunc = func(msg tea.Msg, m *list.Model) tea.Cmd {
		switch msg := msg.(type) {
		case tea.KeyMsg:
			switch {
			case key.Matches(msg, keys.ToggleSelection):
				if i, ok := m.SelectedItem().(listItem); ok {
					i.selected = !i.selected
					return m.SetItem(m.Index(), i)
				}
				return nil
			}
		}
		return nil
	}

	d.ShortHelpFunc = func() []key.Binding { return keys.ShortHelp() }
	d.FullHelpFunc = func() [][]key.Binding { return keys.FullHelp() }

	return d
}

example of list with filtering

There are many UI components from bubbles which we still have not explored here. I hope this gives a rough idea of what bubbletea and bubbles could do in writing CLI programs. If you want to learn more about it or products from charm.sh visit charm.sh on YouTube.

I hope I will have time later to showcase more usages of the libraries. Until then, happy coding!

Cli
Alex Ho
Authors
Software Developer
Experienced software engineer with interests in web and cloud technologies