DEV Community

Cover image for 7 TUI libraries for creating interactive terminal apps
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

7 TUI libraries for creating interactive terminal apps

Written by Yashodhan Joshi✏️

When writing applications, a good user interface is just as important as the actual app’s functionality. A good user interface will make the user continue using the app, whereas a bad, clunky one will drive users away.

This also applies to applications that are completely terminal-based, but making them can be trickier than normal due to the limitations of the terminal.

In this post, we will review seven different TUI libraries that can help us with building interactive terminal applications. Specifically, we will go over:

  • Ratatui
  • Huh?
  • BubbleTea
  • Gum
  • Textual
  • Ink
  • Enquirer

Additionally, we will go through a brief comparison between them that will help you choose the library for your next terminal-based project.

Introduction to terminal user interfaces

Terminal user interfaces (TUIs) can be categorized into two different types: one that is completely flag-based and one that is more like a GUI application.

Most of the Unix command-line utilities provide a flag-based interface. We specify the flags using - or -- and a short or long flag name, and the application changes its behavior accordingly. These are extremely useful when the application is to be used in non-interactive way or as a part of a shell script.

However, they can get notoriously complex — Git is feature-rich and incredibly useful, but its flag and sub-command-based interface can get unwieldy. There is also a website dedicated to generating random and remarkably real-looking fake Git flags.

Another consideration for terminal apps is whether they are being used interactively or as part of a pipeline. For example, if you just run ls , it will output different colors for normal files, directories, and so on in most of the shells. If you run it as ls | cat , it will not output colors.

For terminal-based apps, which are intended to be primarily used interactively, the choice is a bit simplified and there’s more flexibility. They can create GUI-like interfaces and take inputs from both the keyboard and mouse.

In this article, we will focus on this second kind of TUI, which is meant to be more GUI-like and can provide a familiar experience to users who don’t have a lot of experience using terminals.

1. Ratatui

Ratatui is a feature-rich library that can be used to create complex interfaces containing elements similar to graphical interfaces, such as lists, charts, tables scrollbars, etc. It is a powerful library with many options, and you can check out its resource of examples to see the possibilities.

For our example, we will be creating a simple directory explorer application. Note that because this is an example, there are lot of unwraps and clones. You should handle these properly in actual applications.

Start with a new project, and add ratatui and crossterm as dependencies:

cargo add ratatui crossterm
Enter fullscreen mode Exit fullscreen mode

We then add the required imports to src/main.rs:

use crossterm::{
    event::{self, KeyCode, KeyEventKind},
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
    ExecutableCommand,
};
use ratatui::{prelude::*, widgets::*};
use std::{
    io::{stdout, Result},
    path::PathBuf,
};
Enter fullscreen mode Exit fullscreen mode

And re-write the main function as follows, but do not run this yet:

fn main() -> Result<()> {
    stdout().execute(EnterAlternateScreen)?;
    enable_raw_mode()?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
    terminal.clear()?;

    stdout().execute(LeaveAlternateScreen)?;
    disable_raw_mode()?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

These are taken from the Ratatui example.

Ratatui directly interfaces with the underlying terminal using crossterm. To correctly render the UI, it needs to set the terminal in raw mode where the typed characters, including arrow keys, are passed directly to the application and not intercepted. Thus, we first need to enable the raw mode and then disable it again before exiting.

Now, we start by declaring some required variables in the main after the terminal.clear():

let mut cwd = PathBuf::from(".");
let mut selected = 0;
let mut state = ListState::default();
let mut entries: Vec<String> = std::fs::read_dir(cwd.clone())
    .unwrap()
    .map(|entry| entry.unwrap().file_name())
    .map(|s| s.into_string().unwrap())
    .collect::<Vec<_>>();
Enter fullscreen mode Exit fullscreen mode

We set the cwd to the current directory and selected to 0. We also create a state, which will store the state information for our list widget and create the initial entries by reading the current directory.

Now we add an infinite loop below this:

loop {
...
}
Enter fullscreen mode Exit fullscreen mode

This will act as the main driving loop for the application. We first add in a line to check for events by doing the following:

if event::poll(std::time::Duration::from_millis(16))? {
  if let event::Event::Key(key) = event::read()? {
    if key.kind == KeyEventKind::Press {
      match key.code {
        KeyCode::Char('q') => {
            break;
        }
        _ => {}
      }
}}}
Enter fullscreen mode Exit fullscreen mode

Here, we first poll for the event, waiting only for 16 milliseconds (similar to their example). This way, we do not block the rendering if there is no event, and instead, skip the processing and continue on with the loop.

If there is an event, we read the event and check if it’s of type Key. If it is, we further check if the event is a key press, at which place we are three brackets in, and make sure that some key was in fact pressed. We check the key code, and if it is q, then we break out of the loop. Otherwise, we simply ignore it.

Then, the code for rendering the list widget is added. First, we create the list widget:

let list = List::new(entries.clone())
  .block(Block::bordered().title("Directory Entries"))
  .style(Style::default().fg(Color::White))
  .highlight_style(
    Style::default()
      .add_modifier(Modifier::BOLD)
      .bg(Color::White)
      .fg(Color::Black),
  )
  .highlight_symbol(">");
Enter fullscreen mode Exit fullscreen mode

We create a new list with entries as the list elements. We set the rendering style to block with the title Directory Entries. This will render the list with borders around it and a title on the top border. We set the element style as a default of white text, as well as the highlight style. The highlighted section will be bolded with black text on a white background. We also set the highlight symbol to > , which will be displayed before the selected item.

Then we actually draw the list UI using:

terminal.draw(|frame| {
    let area = frame.size();
    state.select(Some(selected));
    frame.render_stateful_widget(list, area, &mut state);
})?;
Enter fullscreen mode Exit fullscreen mode

Here, we use state.select method to set which item is selected, and then render it on the frame.

Now, to handle the arrow inputs, we add the following to the match statement for key.code:

KeyCode::Up => selected = (entries.len() + selected - 1) % entries.len(),
KeyCode::Down => selected = (selected + 1) % entries.len(),
KeyCode::Enter => {
    cwd = cwd.join(entries[selected].clone());
    entries = std::fs::read_dir(cwd.clone())
        .unwrap()
        .map(|entry| entry.unwrap().file_name())
        .map(|s| s.into_string().unwrap())
        .collect::<Vec<_>>();
    selected = 0;
}
Enter fullscreen mode Exit fullscreen mode

If the key is an up or down arrow, we change the selected to one item before or after, taking care of wrapping around the first and last item. If the key pressed is Enter, we update the cwd by joining the selected entry to it and reset the entries to entries of this new cwd . Finally, we reset the selected to 0 .

You can run this by running cargo run. Note that this does not handle going a directory back, and panics if you press enter on a file instead of a directory. You can implement them yourself, by taking the above code as a starting point, which can be found in the repo linked at the end.

You should also check out the Ratatui website for more creative examples and detailed information on available widgets.

2. Huh?

Huh? is a Go library, which can be used to take inputs from users in a form-like manner. It provides functions and classes to create specific types of prompts and take user input, such as select, text input, and confirmation dialogues.

First, we create a Go project, add the library as a dependency, and in main.go, import it as:

package main
import "github.com/charmbracelet/huh"
Enter fullscreen mode Exit fullscreen mode

For this example, we will create an interface for a program, which searches for a package with a given name. Users can also provide a version needed to select a registry from a predefined list. We start by declaring the variables for these and set the version to * as the default:

func main() {
  var name string
  version := "*"
  var registry string
}
Enter fullscreen mode Exit fullscreen mode

Then, we create a new form, which is the top-level input class in the library:

form := huh.NewForm(...)
Enter fullscreen mode Exit fullscreen mode

A form can contain multiple groups of prompts. You can think of a group as a “page” in real-life forms. Each group will be rendered to the screen separately and will clear the screen of questions from previous groups. A form must have at least one group:

form := huh.NewForm(huh.NewGroup(...))
Enter fullscreen mode Exit fullscreen mode

In this group, we will add individual questions, starting with name, which is a simple string:

huh.NewInput().
    Title("Package name to search for").
    CharLimit(100).
    Value(&name),
Enter fullscreen mode Exit fullscreen mode

We create an Input element, which takes a single line of text. The Title is displayed as the prompt question to the user. For Input, we can also set the character limit if needed using CharLimit . Finally, we give the reference of the variable to store the user input.

The version input is similar to the name input:

huh.NewInput().
    Title("Version").
    CharLimit(400).
    Validate(validateVersion).
    Value(&version),
Enter fullscreen mode Exit fullscreen mode

If the variable has a value set (like in this case *), then that value will be displayed as the default answer. Validate is used to specify a validation function for the input. The function should take a single parameter typed according to the field’s value and should return nil if input is valid, or an error if it is not. For example, we can define the function as:

func validateVersion(v string) error {
    if v == "test" {
      return errors.New("Test Error")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

This takes a string, because version is of the type string, and returns an error for our special value test . Note that this error will be displayed directly to users and they will be prevented from answering further questions until the error is corrected.

Finally, for the registry input, we use the selection input as:

huh.NewSelect[string]().
    Title("Select Registry to search in").
    Options(
            huh.NewOption("Registry 1", "https://reg1.com"),
            huh.NewOption("Registry 2", "https://reg2.com"),
            huh.NewOption("Registry 3", "https://reg3.com"),
    ).
    Value(&registry)
Enter fullscreen mode Exit fullscreen mode

Each option takes two values — the first is what will be displayed to the user and the second is what will be actually stored in the variable when that option is selected.

Finally, to actually take the input, we use the Run on the form created:

err := form.Run()
if err != nil {
    log.Fatal(err)
}
fmt.Println(name, version, registry)
Enter fullscreen mode Exit fullscreen mode

Error will be returned if there are any issues when taking input (but this does not include any error returned by validation functions.)

We finish the example by printing the variables, but in the actual program, you can use these values to connect to the registry and find the package.

3. BubbleTea

BubbleTea is a Go library inspired by the model-view-update architecture of Elm applications. This separates the model (the data structure), update (the method used for processing the input and updating the state), and view (code for rendering the state to the terminal). Thus, we first define a structure, implement the init, update, and view methods on it, and use that to render the TUI.

For this example, we will create a very simple file explorer that displays a list of files and directories in the current directory. If you select a directory, it changes the list to show the contents of that directory and so on.

We start by creating the Go module using go mod init bubbletea-example and adding the package. We then declare our imports:

package main
import (
        "fmt"
        tea "github.com/charmbracelet/bubbletea"
        "log"
        "os"
)
Enter fullscreen mode Exit fullscreen mode

We define our model as:

type model struct {
  cwd      string
  entries  []string
  selected int
}
Enter fullscreen mode Exit fullscreen mode

Here, the cwd field will be used to store the current directory path, the entries field will be used to store the directory entries, and selected field stores the index of entry where the user cursor is currently.

We define a function to get an instance of our struct with default values:

func initialModel() model {
  entries, err := os.ReadDir(".")
  if err != nil {
    log.Fatal(err)
  }
  var dirs = make([]string, len(entries))
  for i, e := range entries {
    dirs[i] = e.Name()
  }
  return model{
    cwd:      ".",
    entries:  dirs,
    selected: 0,
  }
}
Enter fullscreen mode Exit fullscreen mode

We first read the dir from which the program was invoked, then create a list of the entry names, and create a model using this data.

We also need to add a function Init, which will be called the first time the TUI is created for the struct. It should be used to perform any I/O if needed. As we don’t need any, we simply return nil from it.

func (m model) Init() tea.Cmd {
        return nil
}
Enter fullscreen mode Exit fullscreen mode

We then add the Update method as:

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":
        return m, tea.Quit
      case "up":
        if m.selected > 0 {
                m.selected--
        }
      case "down":
        if m.selected < len(m.entries)-1 {
                m.selected++
        }
      case "enter", " ":
        entry := m.entries[m.selected]
        m.selected = 0
        m.cwd = m.cwd + "/" + entry
        entries, err := os.ReadDir(m.cwd)
        if err != nil {
                log.Fatal(err)
        }
        var dirs = make([]string, len(entries))
        for i, e := range entries {
                dirs[i] = e.Name()
        }
        m.entries = dirs
      }
    }
    return m, nil
}
Enter fullscreen mode Exit fullscreen mode

This method gets a parameter of type Msg , which is an interface implemented by both keyboard and mouse input events. We check if the concrete type of the msg is KeyMsg, indicating that the user has pressed some key, and then switch on its value.

If the key pressed is q or Ctrl+C , we return a special message of Quit, which quits the application. If the key is up or down, we update the selected value accordingly. If the key is Enter, we update the cwd by appending the currently selected entry with the current cwd and then update the entry list. Finally, we return the model struct itself.

Then, for the rendering method:

func (m model) View() string {
  s := "Directory List\n\n"
  for i, dir := range m.entries {
    cursor := " "
    if m.selected == i {
            cursor = ">"
    }
    s += fmt.Sprintf("%s %s\n", cursor, dir)
  }
  s += "\nPress q to quit.\n"
  return s
}
Enter fullscreen mode Exit fullscreen mode

We start with a fixed string, then iterate over the entries — adding them one by one to the string. If the entry currently has the cursor on it, we indicate that with > and, finally, indicate the quitting information. We return the string to be displayed from the function.

In main, we invoke this app as:

func main() {
  p := tea.NewProgram(initialModel())
  if _, err := p.Run(); err != nil {
    fmt.Printf("Error : %v", err)
    os.Exit(1)
  }
}
Enter fullscreen mode Exit fullscreen mode

We create a new program with the default state of our model, and call run on it. This will display the TUI application and handle the input provided using the update method.

4. Gum

Gum is a batteries-included library that can be used to take inputs for a shell script. When using Gum, you don’t have to write any Go code, and you can use various inbuilt types of prompts to take input. Of course, for this to work, the gum binary must be installed on the user’s machine. This library is also by the same developers who wrote Huh? and BubbleTea.

Here, we will recreate the same program as Huh? example, but using Gum in shell scripts. First, install the Gum binary as per instructions on their GitHub repo here.

We can use Gum’s sub-commands to take specific types of inputs, such as:

  • gum input — Takes a single-line text input
  • gum input --password — Displays * instead of what is typed
  • gum choose — To select one of the given options
  • gum file — To select a file from a given directory

…and so on. We can recreate the huh example as:

#! /bin/bash

echo "Name of the package to serach for :"
NAME=$(gum input)
echo "Version of package to find"
VERSION=$(gum input --value="*")
echo "Select registry :"
REGISTRY=$(gum choose https://reg{1..3}.com)

echo "name $NAME, version $VERSION, registry $REGISTRY"
Enter fullscreen mode Exit fullscreen mode

We first use echo to display a prompt to the user, take the input, and store it in the corresponding variables. We use the --value flag to set the default value for the input.

Gum prints out the value given by the user, so the $(…) expression evaluates to the user input and is subsequently stored in the variable. Make sure that the input is stored somewhere — either in a variable or redirected to a file using > — otherwise it is printed on the terminal. This can be dangerous in the case of password inputs.

5. Textual

Textual is a Python library that can be used for creating rich user interfaces in the terminal. It has similar elements to GUI. For this example, we create a simple directory explorer similar to the Ratatui and BubbleTea examples.

We start by installing the library:

>pip install textual textual-dev
Enter fullscreen mode Exit fullscreen mode

Then, we can add the imports and a helper function:

import os
from textual.app import App 
from textual.widgets import Header, Footer, Button, Static

def create_id(s):
    return s.replace(".","_").replace("@","_")
Enter fullscreen mode Exit fullscreen mode

We create a class that extends Textual’s Static class. This will be used to display the list of directory entries and handle user input:

class DirDisplay(Static):
    directory = "."
    dir_list = [Button(x,id=x) for x in os.listdir(".")]

    def on_button_pressed(self, event):
        self.directory = os.path.join(self.directory,str(event.button.label))
        self.dir_list = [];
        for dir in os.listdir(self.directory):
            self.dir_list.append(Button(dir,id=create_id(dir)))
        self.remove_children()
        self.mount_all(self.dir_list)

    def compose(self):
       return self.dir_list 
Enter fullscreen mode Exit fullscreen mode

We start by defining the directory initialized with . , corresponding to the current directory. We then initialize the dir_list using os.listdir() and creating a button for each of the entries using list comprehension.

The first parameter of the Button constructor is the label of the button, which can be any text. The id parameter needs to be unique for each button so we can figure out which button was pressed in the button click handler. The id has several restrictions, such as no special characters or starting with any numbers. We thus use the create_id helper function to convert the directory entry name to an id-compatible string.

The compose method is called once at the beginning and must return the widgets to render. Here, we return the dir_list, which is the list of buttons.

on_button_pressed is a fixed method name and is used as the button-click handler by the Textual library. It gets an event, which has details of the button clicked. In this, we handle the button click by first concatenating the existing directory with the label of the pressed button, which indicates the directory entry name). We then re-initialize the dir_list by creating buttons for each entry in this new path.

We then explicitly remove the current children to clear out the old entries and then call mount_allto render buttons for the new entries.

Our main app has a separate class:

class Explorer(App):
   def compose(self):
        yield Header()
        yield DirDisplay()
        yield Footer()
Enter fullscreen mode Exit fullscreen mode

This is a pretty simple class extending the App class from the library. Here, instead of returning a complete list, we use yield to return the components one by one. For the components, we use the inbuilt Header, then our DirDisplay, and, finally, the inbuilt Footer components.

Finally, we run this app as:

if __name__ == "__main__":
    app = Explorer()
    app.run()
Enter fullscreen mode Exit fullscreen mode

And run it as python path/to/main.py. Textual also allows the use of CSS to style the components. You can view the detailed guide in their documentation.

6. Ink

Ink is a JavaScript library that uses React and React components to develop the TUI. If you are already familiar with the React ecosystem and are developing your application in Node, this can be a great option for you to take user input.

To get started, we use their scaffolding command to set up our project:

npx create-ink-app ink-example
Enter fullscreen mode Exit fullscreen mode

This will create the project directory, npm project, install the dependencies, etc. As this is a React-based project, it will also set up the Babel to transpile JSX into JS. If you already have a Node app, you can also manually add ink and react as dependencies and set up Babel to compile it. The instructions for that can be found here.

The default template installs several other dependencies, such as xo, meow, and ava, which can be ignored for this example. In the app.js file under source dir, we have the example component that prints hello name with the name part in green.

The Text component renders text with various styling options such as color, background color, italics, bold, etc. The Box component can be used for creating layouts similar to flexbox.

For this example, we will create a simple app that takes text as input and saves it in a file. We will change the source/cli.js as:

import React from 'react';
import {render} from 'ink';
import App from './app.js';

render(<App />);
Enter fullscreen mode Exit fullscreen mode

In the source/app.js , we will update imports and app definition to:

import React, {useState} from 'react';
import {Text, Box, Newline, useInput, useApp} from 'ink';

export default function App() {}
Enter fullscreen mode Exit fullscreen mode

In the App function, we will declare a state using useState hook to store the inputted text. We also get the exit function from useApp hook to exit the app when the user presses Ctrl+D . Note that the App in hook’s name is not related to the component’s name:

const [ text, setText ] = useState(' ');
const { exit } = useApp();
Enter fullscreen mode Exit fullscreen mode

We will return JSX from the function, which will be rendered as our terminal UI. We will start by printing a heading:

return (
    <>
      <Box justifyContent="center">
        <Text color="green" bold>
                Input your text
        </Text>
        <Newline />
      </Box>
    </>
);
Enter fullscreen mode Exit fullscreen mode

The Box component is given justifyContent to center align the header, and we put the text inside of it with a color green and in bold. We also add a new line to separate our heading from the use inputted text.

Next, we add the actual text. Below is the header’s box component:

{text.split('\n').map((t, i, arr) => (
  <Text key={i}>
    {'>'}
    {t}
    {i == arr.length - 1 ? '_' : ''}
  </Text>
))}
Enter fullscreen mode Exit fullscreen mode

We split the text content by a new line and map it to a Text component. We prepend > to indicate the input, then the actual text line. For the last line, we append _ as an indication of a cursor.

To handle the input, we use the useInput hook provided by the library, before the return statement:

useInput((input, key) => {
    if (key.ctrl && input === 'd') {
      // save the text in a file
      exit();
    } else {
      let newText;
      if (key.return) {
              newText = text + '\n ';
      } else {
              newText = text + input;
      }
      setText(newText);
    }
});
Enter fullscreen mode Exit fullscreen mode

We get two parameters: input and key . input stores the entered text when there is a key-press. However, if the key is a non-text key, such as Ctrl, Esc , or Return, we get that information in the second argument as a boolean property. You can see the docs for a list of available keys, but in the example, we use Ctrl and Return.

If Ctrl+D is pressed, we save the text entered until then in a file and then exit. Otherwise, we check if the Return key is pressed and append a new line to the text. For all other key presses, we append the input to the text. Finally, we set the text using setText call.

You can see the output by running npm run build && node dist/cli.js : Ink Library Output The header is centered and the inputted text is displayed correctly. This does not handle backspace yet. You can take the code from the repo linked below, and implement the backspace and delete key handling.

7. Enquirer

Enquirer is a JavaScript library for designing question-based TUIs, somewhat similar to Huh?. Enquirer allows you to create a prompt-based interface similar to the one presented when we run npm init. It has various kinds of prompts, including text input, list input, password, selections, etc. There are some other similar libraries, such as Inquirer, which you might want to check if this does not fit your requirements.

Start by creating a new package and running npm init. Add enquirer as a dependency by running npm i enquirer. We will use this to take initial player data for a game.

We import the prompt from the library and create a prompt object:

const { prompt } = require("enquirer");
const results = prompt(...);
Enter fullscreen mode Exit fullscreen mode

If we want a single question, we can directly use the individual prompt classes such as input, select, etc., but for multiple questions, we can pass an array of objects with appropriate fields defined to the prompt function:

const results = prompt([...]);
Enter fullscreen mode Exit fullscreen mode

We will define a main function, call await on the results, and then call the main function itself:

async function main() {
  const response = await results;
  console.log(response);
}
main();
Enter fullscreen mode Exit fullscreen mode

Now, let us construct the questions one by one. First up is the character name. Type input is used for single-line text:

{
  type: "input",
  name: "name",
  message: "What is name of your  character?",
},
Enter fullscreen mode Exit fullscreen mode

We give the type as input. The value of name field will be used as the key in the results object returned and the message will be displayed to the user as the prompt for this question.

Similarly, we add the next question to select the class of the character:

{
  type: "select",
  name: "class",
  message: "What is your character class?",
  choices: [
    {
      name: "Dwarf",
      value: "dwarf",
    },
    { name: "Wizard", value: "wizard" },
    { name: "Dragon", value: "dragon" },
  ],
},
Enter fullscreen mode Exit fullscreen mode

type, name, and message are similar, and here, we also provide choices. This is an array, with each object having a name that’s displayed to the user and a value that’s set in the answers object when a user selects the option.

Then, we give the user an option to customize the experience with “advanced” options by using a toggle (a yes or no question):

{
  type: "Toggle",
  name: "custom",
  message: "Do advance customization?",
  enabled: "Yes",
  disabled: "No",
},
Enter fullscreen mode Exit fullscreen mode

The enabled/disabled strings are shown to the users, but the actual value is set to true/false, depending on whether the user selects the enabled or disabled option.

Then, we give the user two options to customize the experience: difficulty and item randomness:

{
  type: "select",
  name: "difficulty",
  message: "Select difficulty level",
  choices: [
    { message: "Easy", value: "1" },
    { message: "Medium", value: "2" },
    { message: "Hard", value: "3" },
  ],
  initial: "2",
  skip: function () {
    return !this.state.answers.custom;
  },
},
Enter fullscreen mode Exit fullscreen mode

This is again a select type, but two fields are added — initial and skip . The initial field must be a string or a function returning a string, which will be set as the default value. The skip function must return a boolean and will be used to decide if the question should be skipped.

Note that this is an anonymous function and not an arrow function. This is because we need the this object to be bound correctly. We can then access the previous answers by using this.state.answers and use the answer custom to decide if this question should be asked to the user or not.

If the user has selected No for the customization, then this question will not be displayed. Its answer will be set to the initial value.

Similarly, we define the item randomness:

{
  type: "select",
  name: "random",
  message: "Select item randomness level",
  choices: [
    { message: "Minimum", value: "1" },
    { message: "Low", value: "2" },
    { message: "Medium", value: "3" },
    { message: "High", value: "4" },
    { message: "Maximum", value: "5" },
  ],
  initial: "3",
  skip: function () {
packages    return !this.state.answers.custom;
  },
},
Enter fullscreen mode Exit fullscreen mode

Now, if you run the project, you will get the prompts, and finally, the answer object will be logged.

Comparison table

Below is a comparison of the seven TUI libraries we discussed. Keep in mind that some columns in the following table are subjective, such as ease of use:

Library Language Ease of use Inbuilt components End user requirements
Ratatui Rust Complex Yes Final compiled Binary
Huh? Go Easy Yes Final compiled Binary
BubbleTea Go Medium No Final compiled Binary
Gum Shell Easy Yes User needs the Gum binary along with script
Textual Python Complex Yes User needs the textual Python library along with the script
Ink JS Easy if familiar With React Yes User needs node and npm dependencies
Enquirer JS Easy Yes User needs node and npm dependencies

Comparison summary

Before we wrap up, let’s go into more depth and discuss specific use cases, etc.:

  • Ratatui: If your application is in Rust or you want to create a TUI using some Rust libraries, then Ratatui is obviously a good fit. It has a lot of inbuilt widgets that can satisfy most needs and also provides granular control options if needed. On the other hand, it can get verbose and might be a bit tricky if you are a beginner in Rust
  • Huh?: If your application is in Go and your input can be done in a simple question-based format, this might be for you. It provides several inbuilt prompt options. However, if you need a more GUI-like interface, this might not fit the requirements
  • BubbleTea: This gives you a complete model-view-update architecture like Elm, so if you are familiar with Elm, this can be an easy way in Go’s TUI libraries. However, the final output needs to be constructed as a string, so if you want a fancy display, you will need to construct it yourself by string construction
  • Gum: Gum is extremely useful for taking input in shell scripts without having to write code in other languages or deal with shell-specific quirks while taking input. It provides some good inbuilt input types, however, you will need the Gum binary installed on the user’s system. That is an added dependency you need to care about. This can be a great fit if you are writing shell scripts for yourself and want robust input handling without having to deal with shell’s input methods
  • Textual: If you want TUI in Python, this would be a good library. While you need the Textual package to be installed on the user’s machine, if you are using any Python dependencies apart from standard lib, you need to instruct the user to install them anyway. This might not be that problematic
  • Ink: Ink is great if you are familiar with the React ecosystem and want to write TUI in JS. It needs a compile and run cycle, but so does any React application
  • Enquirer: Similar to Huh?, if your input needs can be satisfied by just question-based input, then this is a great library to use in JS for TUI. It provides a lot of inbuilt prompts and ways to write custom prompts as well

Conclusion

In this post, we went over seven different libraries that can be used to implement various kinds of TUIs . Now you can use the appropriate library in your project to make your interface beautiful and helpful for the user in the terminal.

You can find the example code for these examples in the repo here. Thank you for reading!


Get set up with LogRocket's modern error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.

NPM:

$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
Enter fullscreen mode Exit fullscreen mode

Script Tag:

Add to your HTML:

<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

Top comments (0)