Introduction #

As developers we tend to spend a lot of time in the terminal. It is a great place to exercise precise control over our computers. And it is a great place to automate tasks. However, there are some rough edges to this experience. For example, even though the interaction metaphor w/ CLI apps is a conversation, we have to be very precise in the language we use in this conversation. Lots of trial and error, patience and resilience are required to make it work. And it does not have to be this way.

To use a racing analogy, terminals are like race cars. They are fast and powerful, and you can exercise direct and precise control over them. But if you get things wrong, there can be consequences. Porsche is a car company that wins endurance races, and a very long time ago, they decided to make a race car that was friendly to the ergonomics of their drivers.

The thinking was that if the driver is comfortable, they will perform better, and the 24 hour race wins will be just a little bit closer within reach. Similarly, we can add some interactivity to our CLI apps to make them more ergonomic to use. And we can do this without going full TUI. We can do this in a way that is additive to the existing CLI experience. We can “tuify” our CLI apps that are built using clap.

For more information on general Rust type system design (functional approach rather than object oriented), please take a look at this paper by Will Crichton demonstrating Typed Design Patterns with Rust.

CLI design concepts #

Here are some great resources to learn more about good CLI design concepts. The Rust crate clap is used by a lot of Rust apps to implement this CLI. And in this tutorial we will take a look at how to add some interactivity to these clap CLI apps using the r3bl_tuify crate.

Note that these resources are all about CLI and not TUI. There isn’t very much information out there about TUIs. It is a new and evolving space.

  1. Command Line Interface Guidelines
  2. clap docs
  3. clap command and subcommand structure guidelines
  4. Hierarchy of configuration

The CLI guidelines above do a great job of explaining how to create a good CLI experience. However they do not cover how to add interactivity to your CLI apps. Why would we want to do this? Let’s take a real example to illustrate the benefits of this next.

Show me #

This example is a little “meta”. The r3bl_tuify crate, that allows interactivity to be added to clap CLI apps, is available as a binary and library. The binary which can be used from the command line (and uses clap) uses the library to provide an interactive experience when certain arguments aren’t provided on the command line.

The idea with the binary target is that you might want to quickly incorporate some interactivity into your shell scripts without getting into the Rust library. In this case, you can use the rt binary target to do that. This binary takes quite a few arguments as you might imagine. However, you don’t have to supply all of them at the start.

So instead of typing this massive command at the start (where cargo run -- simply runs the binary called rt):

cat TODO.todo | cargo run -- select-from-list \
    --selection-mode single \
    --command-to-run-with-each-selection "echo %"

You can simply type the following shorter command and have the app prompt you for the rest of the information that it needs:

cat TODO.todo | cargo run -- select-from-list

Here’s a video of this in action, where the app is prompting the user for two items:

  1. the selection-mode and
  2. command-to-run-with-each-selection interactively 🎉:

The r3bl_tuify crate and clap #

The r3bl_tuify app itself uses clap to parse the command line arguments. Here’s an overview of what that looks like (all of it using the derive macro approach). Here’s a link to the main.rs::AppArgs.

#[derive(Debug, Parser)]
#[command(bin_name = "rt")]
#[command(about = "Easily add lightweight TUI capabilities to any CLI apps using pipes", long_about = None)]
#[command(version)]
#[command(next_line_help = true)]
#[command(arg_required_else_help(true))]
pub struct AppArgs {
    #[clap(subcommand)]
    command: CLICommand,

    #[clap(flatten)]
    global_opts: GlobalOpts,
}

#[derive(Debug, Args)]
struct GlobalOpts {
    /// Print debug output to log file (log.txt)
    #[arg(long, short = 'l')]
    enable_logging: bool,

    /// Optional maximum height of the TUI (rows)
    #[arg(value_name = "height", long, short = 'r')]
    tui_height: Option<usize>,

    /// Optional maximum width of the TUI (columns)
    #[arg(value_name = "width", long, short = 'c')]
    tui_width: Option<usize>,
}

#[derive(Debug, Subcommand)]
enum CLICommand {
    /// Show TUI to allow you to select one or more options from a list, piped in via stdin 👉
    SelectFromList {
        /// Would you like to select one or more items?
        #[arg(value_name = "mode", long, short = 's')]
        selection_mode: Option<SelectionMode>,

        /// Each selected item is passed to this command as `%` and executed in your shell.
        /// For eg: "echo %". Please wrap the command in quotes 💡
        #[arg(value_name = "command", long, short = 'c')]
        command_to_run_with_each_selection: Option<String>,
    },
}

The things to note are that the selection_mode and command_to_run_with_each_selection fields of the CliCommand::SelectFromList enum are optional. This is where the r3bl_tuify crate comes in. It will prompt the user for these two fields if they are not supplied on the command line.

You can add this programmatically using the library to your existing CLI apps.

The piping option using the binary is severely limited, so the library option is strongly recommended. The binary is more of a convenience for shell scripts only on Linux.

You can see how to use the library to perform this interactivity in main.rs::show_tui().

Here are two examples of adding interactivity.

Example 1: Add interactivity using a list selection component #

Here’s an example of adding interactivity using a list selection component. This is useful when the values that a field can take are known in advance. In this example, they are since selection-mode is a clap EnumValue that can only take one of the following values: single, or multiple.

In this scenario, --selection-mode is not passed in the command line. So it only interactively prompts the user for this piece of information. Similarly, if the user does not provide this information, the app exits and provides a help message.

cat TODO.todo | cargo run -- select-from-list --command-to-run-with-each-selection "echo %"
// Handle `selection-mode` is not passed in.
let selection_mode = if let Some(selection_mode) = maybe_selection_mode {
    selection_mode
} else {
    let possible_values_for_selection_mode =
        get_possible_values_for_subcommand_and_option(
            "select-from-list",
            "selection-mode",
        );
    print_help_for_subcommand_and_option("select-from-list", "selection-mode").ok();

    let user_selection = select_from_list(
        "Choose selection-mode".to_string(),
        possible_values_for_selection_mode,
        max_height_row_count,
        max_width_col_count,
        SelectionMode::Single,
    );

    let it = if let Some(user_selection) = user_selection {
        if let Some(it) = user_selection.first() {
            println!("selection-mode: {}", it);
            SelectionMode::from_str(it, true).unwrap_or(SelectionMode::Single)
        } else {
            print_help_for("select-from-list").ok();
            return;
        }
    } else {
        print_help_for("select-from-list").ok();
        return;
    };

    it
};

Example 2: Adding interactivity using a text input field #

Here’s an example of adding interactivity using a text input field. This is useful when the values that a field can take are not known in advance. The r3bl_tuify crate uses the reedline crate to do this.

Fun fact: reedline is the text input field (line editor) that is used in nushell.

In this scenario, --command-to-run-with-each-selection is not passed in the command line. So it only interactively prompts the user for this piece of information. Similarly, if the user does not provide this information, the app exits and provides a help message.

cat TODO.todo | cargo run -- select-from-list --selection-mode single
// Handle `command-to-run-with-each-selection` is not passed in.
let command_to_run_with_each_selection =
    match maybe_command_to_run_with_each_selection {
        Some(it) => it,
        None => {
            print_help_for_subcommand_and_option(
                "select-from-list",
                "command-to-run-with-each-selection",
            )
            .ok();
            let mut line_editor = Reedline::create();
            let prompt = DefaultPrompt {
                left_prompt: DefaultPromptSegment::Basic(
                    "Enter command to run w/ each selection `%`: ".to_string(),
                ),
                right_prompt: DefaultPromptSegment::Empty,
            };

            let sig = line_editor.read_line(&prompt);
            match sig {
                Ok(Signal::Success(buffer)) => {
                    if buffer.is_empty() {
                        print_help_for("select-from-list").ok();
                        return;
                    }
                    println!("Command to run w/ each selection: {}", buffer);
                    buffer
                }
                _ => {
                    print_help_for("select-from-list").ok();
                    return;
                }
            }
        }
    };

// Actually get input from the user.
let selected_items = {
    let it = select_from_list(
        "Select one line".to_string(),
        lines,
        max_height_row_count,
        max_width_col_count,
        selection_mode,
    );
    convert_user_input_into_vec_of_strings(it)
};

Next steps #

There are many more components that need to be added to make it easier to “tuify” lots of existing CLI experiences. Things like multi line editor component w/ syntax highlighting, scroll view, form input fields, and more. If you would like to contribute to this effort, it would be great to have your help.

Build with Naz video series on developerlife.com YouTube channel #

If you have comments and feedback on this content, or would like to request new content (articles & videos) on developerlife.com, please join our discord server.

You can watch a video series on building this crate with Naz on the developerlife.com YouTube channel.

👀 Watch Rust 🦀 live coding videos on our YouTube Channel.



📦 Install our useful Rust command line apps using cargo install r3bl-cmdr (they are from the r3bl-open-core project):
  • 🐱giti: run interactive git commands with confidence in your terminal
  • 🦜edi: edit Markdown with style in your terminal

giti in action

edi in action

Related Posts