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:
Additionally, we will go through a brief comparison between them that will help you choose the library for your next terminal-based project.
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.
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 unwrap
s and clone
s. You should handle these properly in actual applications.
Start with a new project, and add ratatui
and crossterm
as dependencies:
cargo add ratatui crossterm
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, };
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(()) }
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<_>>();
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 { ... }
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; } _ => {} } }}}
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(">");
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); })?;
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; }
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.
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"
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 }
Then, we create a new form
, which is the top-level input class in the library:
form := huh.NewForm(...)
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(...))
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),
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),
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 }
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(®istry)
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)
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.
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" )
We define our model as:
type model struct { cwd string entries []string selected int }
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, } }
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 }
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 }
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 }
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) } }
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.
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 inputgum input --password
— Displays *
instead of what is typedgum choose
— To select one of the given optionsgum 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"
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.
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
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("@","_")
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
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_all
to 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()
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()
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.
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
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 />);
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() {}
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();
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> </> );
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> ))}
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); } });
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
:
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.
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(...);
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([...]);
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();
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?", },
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" }, ], },
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", },
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; }, },
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; }, },
Now, if you run the project, you will get the prompts, and finally, the answer object will be logged.
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 |
Before we wrap up, let’s go into more depth and discuss specific use cases, etc.:
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 problematicIn 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!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowHandle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
Design React Native UIs that look great on any device by using adaptive layouts, responsive scaling, and platform-specific tools.
Angular’s two-way data binding has evolved with signals, offering improved performance, simpler syntax, and better type inference.