Creating Beautiful Terminal Apps With JS

Creating Beautiful Terminal Apps With JS

Using a react like library for JS to create cool terminal UI apps

2022-02-24

The CLI is often relegated, in our imagination, to the realm of hackers and Unix wizards of days of yore. GUIs and web design have certainly received more attention in recent decades than the humble terminal.
Yet, as millions of people learn about code, the CLI has more users than ever. So maybe it’s time we focus on making CLI apps that are more than just functional. We can make them beautiful.

In this article, we’ll look at how we can use beauty and elegance to create better Terminal User Interfaces (TUIs). To accomplish this goal, we’ll rely primarily on Ink, a library that allows us to use React (the web frontend library from Facebook) to design command line apps.

demo

Hopefully you’re nice and hungry to start learning about (better!) CLI design. So let’s dive in!

The Old-Fashioned WAY

Before we make fancy UIs, there are two things any terminal app to make life easier for users:

  1. intuitively named command line arguments

This is pretty easy to get right intuitively, but we’ll cover the most basic factors of designing good command line options.

There should be two versions of most commands, a long form (something like --option) and an accompanying short form (to match the previous example, -o). No need to be super strict about this, but it’s a good general principle.
Why? Because when writing scripts, users prefer long options that clarify the meaning, but when on the command line they usually want quick, easy options they can type without having to spell out each word.

You should also have a help menu available via the -h and --help options. Ink, the library we use in this guide, actually has a great system for managing command line options, which you can see in action in the default app created by npx create-ink-app

  1. a man page (or some other easy form of documentation)

Creating a man page is pretty easy to do and way underappreciated these days! I mean, actually, the “normal” process for doing it manually is actually kind of wierd and obscure. It uses an old AT&T text processing language called troff that looks like this:

.\" Manpage for nuseradd.
.\" Contact vivek@nixcraft.net.in to correct errors or typos.
.TH man 8 "06 May 2010" "1.0" "nuseradd man page"
.SH NAME
nuseradd \- create a new LDAP user 
.SH SYNOPSIS
nuseradd [USERNAME]
.SH DESCRIPTION

So that’s what people used to deal with. But the good news is that these days, you can do it very easily using Grapse, an online tool available for free: https://roperzh.github.io/grapse/

Now that we’ve covered some classic TUI design themes, we can (finally) get to the fun part!

Creating a simple app with Ink

Ink is based on React, which you can learn more about here: https://reactjs.org/docs/hooks-intro.html

If you don’t know JavaScript or React, don’t fret, later in the article we’ll show you similar frameworks for other languages. So let’s consider something really simple we can make to see what we can do with Ink. How about a nice-looking authentication form. When we’re done, it’ll work like this:

auth

Since Ink is basically just React for the command line, we’ll design our authentication app as a React component. Successful authentication will be governed by a callback passed as a prop (this is defined separately, it can call an API, a DB, or whatever. We won’t worry about that today). And the things we want to hide behind the auth form are child components of ours, which are rendered once auth is successful. So our app will look kinda like this:


// InkAuthForm is the component we'll be making
<InkAuthForm authCb={verifyCredentials}>
	// This content will become available once authentication is successful
	<Text color={"greenBright"}>Authenticated!</Text>
</InkAuthForm>

To even have an app, we must set up an Ink project. It’s super easy to do, just run the command npx create-ink-app in the directory where you want the code to be.

We’ll put our code in source/ui.jsx (or ui.tsx if you want to be cool and use TypeScript, which we will for the rest of this article).

First, we’ll make a “skeleton” of our component that we can fill in with functionality as we go:


const InkAuthForm: any = ({authCb, children}: {authCb: Function, children: React.ReactNode}) => {
	const [attempts, setAttempts] = useState(0);
	const [loggedIn, setLoggedIn] = useState(false);

	useEffect(() => {
		// another login attempt has occurred!
		if (attempts > 0) {
			if (authCb(username, password)) {
				setLoggedIn(true);
			}
			else {
				console.log('Authentication failed...');
			}
		}
	}, [attempts])

	// if login is successful, render child components
	if (loggedIn) {
		return children;
	}

	return (
		<Box justifyContent='center'>
			// the login form goes here
		</Box>
	)
}

So we’ve got a box to put our input in, let’s see if we can do anything fun and fancy to spruce it up a little.

For example, we can add a border. Ink has plenty of options for border styling, but I prefer the “classic” border look (with plus signs for corners and dash characters). I know this is supposed to be a modern TUI intro, but I think the classic terminal borders look best, so I’ll use those. Feel free to experiment with the other styles, though, which can be found here: https://github.com/vadimdemedes/ink#borders

If we add that, then the border is really tight around the content, so we’ll also add some padding. And finally, if the user types a bunch of characters, the input might begin to ‘spill’ outside of the limits of the box. To prevent that, we’ll call a function that dynamically sizes the box to be big enough to fit the content (we’ll add the actual code for the function later, for know we’ll just call it minFormWidth()).

Oh, and we need to set the flexDirection to "column" so the input fields will be on top of each other, instead of side to side.

That all probably sounded pretty confusing, but that confusion abates quickly looking at the code:


<Box justifyContent='center'>
	<Box width={minFormWidth()} borderStyle='classic' padding={1}>
		<Box flexDirection="column">
			// the actual form goes here
		</Box>
	</Box>
</Box>

To add the real form inputs, we need an additional dependency which can be installed like so: npm install ink-text-input. Along with the text inputs, we’ll also need logic for updating state, and switching focus (ie, when the user enters their username, focus should switch to the password input).

Hmm, how do we implement the logic using React? Well, we’ll use the useEffect hook to detect changes to attempts, a bit of state that is incremented when the password is submitted. When attempts is updated, we use the auth callback authCb to see if the entered creds are correct. If so, we render the child components, and if not, we console log a failure message and empty out the username and password. Oh, and we set the focus back to the username so they can re-enter everything.

Let’s see how that comes out:


const InkAuthForm: any = ({authCb, children}: {authCb: Function, children: React.ReactNode}) => {
	const [username, setUsername] = useState("");
	const [password, setPassword] = useState("");
	const [usernameEntered, setUsernameEntered] = useState(false);
	const [attempts, setAttempts] = useState(0);
	const [loggedIn, setLoggedIn] = useState(false);

	useEffect(() => {
		if (attempts > 0) {
			if (authCb(username, password)) {
				setLoggedIn(true);
			}
			else {
				console.log('Authentication failed...');
				setUsernameEntered(false);
				setUsername('');
				setPassword('');
			}
		}
	}, [attempts])

	if (loggedIn) {
		return children;
	}

	const minFormWidth = () => 
		Math.max(30, ...[username, password].map(e => 15+e.length));

	return (
		<Box justifyContent='center'>
			<Box width={minFormWidth()} borderStyle='classic' padding={1}>
				<Box flexDirection="column">
					<Box>
						<Box marginRight={1}><Text>Username:</Text></Box>
						<TextInput 
							value={username} 
							focus={!usernameEntered} 
							onSubmit={()=>setUsernameEntered(true)} 
							onChange={setUsername} />
					</Box>

					<Box>
						<Box marginRight={1}><Text>Password:</Text></Box>
						<TextInput 
							value={password} 
							focus={usernameEntered} 
							onSubmit={()=>setAttempts(attempts+1)} 
							onChange={setPassword}
							mask="*" />
					</Box>

				</Box>
			</Box>
		</Box>
	)
}

Phew! Our code is growing quick, but it’s pretty much done. React is so fun and simple to code in, it really does feel like “just JS” even despite the JSX magic.

The only thing remaining is to implement it into our main app:


const App: FC<{}> = () => {
	return (
		<InkAuthForm authCb={(username: string, password: string)=>username==='admin' && password}>
			<Text color={"greenBright"}>Authenticated!</Text>
		</InkAuthForm>
	);
}

Now we can run the app and look at our beautiful interface! Well, maybe beautiful is still a bit of a stretch. But we’ve certainly got a good start!

If you want to get more into the TUI scene (and I wouldn’t blame you, it’s an awesome scene!), check out the Unix ricing subreddit.

The TUI space is still very much not commercialized. So the communities are small but very pure - just nerds and hackers having fun and seeing what cool things they can make their system do. Contributing something even modest will usually be very well received by the community, so don’t be shy!

What if I don’t know JS

No problem, here are similarly cool frameworks for languages you might know:

They’re all pretty great, especially Rust’s tui lib. Each has its own underlying philosophy, so pick the one that meshes best with your outlook and make something crazy!

Even within JS, there are lots of other options. Ink is only so popular because of it’s relative approachability for engineers who are already familiar with React.

If you want something more classic, with all of the power but less of the “modern” design theory, you can check out the mother of all TUI libraries: ncurses. It’s surprisingly easy to use.

Have fun!

Conclusion

I vehemently reject the narrative that CLI is imminently doomed. I imagine the humble terminal as more than a relic of computing’s history - what if it is another modern interface, just like an iPhone app or JS-laden web page? After all, the web itself is decades old, yet we see no reason to treat it as outdated.

Under the hopeful light of this fresh perspective, we can even see in the distance a future for command line apps. One that includes innovation, and growth. Maybe it’s not where most people would expect innovation to occur, but doesn’t the future usually begin in the most unexpected places? Who knows, maybe the future really is text. Regardless, we can have fun while we find out!