9

Taking React to the Command Line with Ink

 2 years ago
source link: https://blog.bitsrc.io/taking-react-to-the-command-line-with-ink-6872ab61b7b5
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Taking React to the Command Line with Ink

Use React in the console, have you ever seen something like that before?

This is definitely a combination I was not expecting to see, for some reason when I think about Command Line Interface, I don’t see the UI as being as complex as that of a web application, however, that’s completely wrong.

A user interface can be as complex as you need it to be anywhere, and creating it when you don’t have the right tools can be a real problem.

This is why the Ink project was so intriguing to me when I realized what I did: it takes the power of React to the command line, not by creating a fully compatible clone, but by including it as a dependency and adding a few extra hooks here and there to make the CLI-life a bit easier.

Let’s take a quick look at this project by building a CLI tool that allows for multiple input and a somewhat less-than-traditional UI.

The app we’re going to be building

For the purposes of testing this library, we’ll be building an application that takes advantage of the Commander npm module.

This module lets you easily create CLI tools by parsing the command line attributes.

However, since we want to have an actual UI, we’re going to provide the user with an input box, and an output box. Both of them will be updated reactively thanks to Ink and we will not have to worry too much about the presentation, only about the actual logic.

Here is what the final output is going to look like:

Granted, it’s not the most advanced UI, but then again, doing just that without any help would take a decent amount of time and effort. Not only that, but to let just the upper half of the screen be updated without affecting the rest would also take quite some time to achieve.

With Ink we’re going to see how easy it is and how simple everything becomes.

Setting up Ink and Commander

While these are 2 npm modules and you can install them following the good old ways, there is an app creator for the Ink package. So just like you would with create-react-app, you can do

mkdir your-app
cd your-app
npx create-ink-app

That’ll set everything up that is required to make sure the Ink application works, you can then go on and install any extra dependencies, like:

npm install commander
npm install string-argv

The first one is the Commander package I said we would use, it doesn’t require any special setup steps. The second one is a small package we’ll use to turn a string into a set of command-line arguments. This one is required because Commander only works with the arguments array, not with a single string.

Understanding the project structure

Once you finish with the creation of the Ink app, you’ll have 2 main files to worry about:

  • The ui.js which would be like your App.js if you’re a React developer, or if you’re not, this file is essentially the place where you start building your UI. This is the main component being rendered.
  • The cli.js file, which acts as the entry point to the whole application. This is the file you’ll want to execute from the command line to get everything going. You won’t need to touch this file, just know about it so you can execute it later.

We’ll also be creating a components folder to put the one we’re going to be building, and a lib folder to hold the business logic and commands we’re going to be using.

Building the main UI for our application

This is one of the main benefits of using the Ink package, building the UI is actually quite straightforward.

For our layout as you can see, we’ll want to main sections:

  • The “Results” section, where we’re going to be showing the output of our commands. That’s the upper half of the screen.
  • The “Prompter”, which is where we’re going to be collecting the user’s input. That’s the bottom half of the screen.

Each of these is going to be a separate component, and we’ll use the custom markup provided by Ink for that.

The main file, our ui.js needs to be modified to look like this:

There are a few things to notice from the code:

  • I’m using the Box component, which is one of the cornerstones components on Ink. It allows you to create the equivalent of a div but for the console. You can read all about their custom markup in their docs here.
  • I’m also importing my 2 components ( Prompter and Results ) but notice how I’m not using the usual require but rather custom importJsx function. This is because I need the JSX to be parsed by someone else other than Node (who doesn’t know how to do that by default). This dependency is automatically added by Ink, so you’ll have it if you’ve been following along.
  • Last but definitely not least, I communicate both components with my local state I get from the call of React’s own useState hook. This is the part that clearly shows you we actually have access to React on the backend. The state variable goes into the Results component since that’s where the output is going to be displayed and the state setter function goes into the Prompter component (since that’s where the actual business logic is called from).

Handling user input

The other thing that we have to deal with when we’re building a terminal tool, is capturing the user’s input. On the web this is trivial, since the input is there by default, we just need to use it.

In the terminal world, while a bit more complicated, it’s nothing to worry about. Especially since Ink already provides a custom hook for us to handle it: useInput . This hook is kind of low level, since it calls the callback you give it, every time there is a key pressed. That means you’re left to deal with capturing the input and doing whatever you want with it.

Let’s take a look at the code:

The component itself is only in charge of setting the input handler and returning its markup code. Which as you can see is pretty straightforward. We’re using the Box and Text components which are pretty much self-explanatory. There is also some inline styling used, as you can appreciate many of the known properties are carried over from the Web world into the Terminal space (like width and padding ). And I’m just using a local state variable called input to capture the keystrokes and show them on the right spot on the screen. This last step is crucial since otherwise, we would not see the user’s input.

The input handling function is not doing anything too strange. It’s just returning our callback which will receive the string of the key pressed as well as the key object, which allows us to understand which key is pressed using the code associated with it. This is very useful for special keys, like backspace or return since we can’t easily check their string representations.

The logic is the following:

  • As long as we don’t press the Backspace or Return keys, we just concatenate our new character to the existing input state.
  • If we press the backspace, then we delete the last character from the input state variable (line 10 from the above snippet).
  • If we press the return key, then we’re supposed to execute the command. And for that we call the runCommand function exported from the actual stringer module. We also take care of resetting the state of the input variable. This is because by default that won’t happen and it would provide a strange user experience.

As a final note, notice how I’m passing the original setOutput prop all the way into the runCommand function, since its result will be the output we need to see. The problem is that because of the way Commander works, we can’t just return the command’s results. We’ll have to override the output function to handle make it work in our special context.

Turning the text green

A note on styling, as you can see from the original screenshot, the Results component shows part of its content with green text, we can achieve that easily with the color property of the Text component. As you can see here:

The flexDirection property allows us to make sure both Text children are displayed one on top of the other, otherwise they’d be shown one next to the other. And the color on the first Text component is used to change the color of the text.

As you can see styling is quite intuitive and if you’re coming from the front-end world, it should already be second nature to you.

Configuring the Commander

While the Commander module is very straightforward to use in the context of a command-line tool, here we need to do some overriding to make sure it follows the standards we need.

And that means two things:

  1. Overriding its output functions so it can put the result of our commands into the state variable from our original app.
  2. Make sure that if there is a problem with the command, it won’t exit with an error code (which is its default behavior).

Here is the main Commander set-up, let’s first look at the code and then we’ll run through it in detail:

Let’s just look at the parseCommands function. This function gets called every time we want to run a command. It goes through the list of commands, reads their definition and attempts to parse the user’s input to figure out which command to call.

It also does the following:

  • On line 40 we can see how we’re overriding commander’s output streams. Both for normal and error scenarios we’re going for the same function. We could potentially have a separate component to handle the error messages and this would allow us to differentiate and split that output.
  • On line 46 we’re telling it that we don’t want to exit if there is an error. This in turn allows the following try/catch block to work (without this line, the exception never gets thrown).
  • On line 48 we capture the error thrown if the command is invalid, and we also redirect it into the output function. This allows us to display the error messages we wanted on the Results component.

Running the commands

Before getting into the above flow, we call the runCommand function, which as I already hinted at, needs to fake a bit the input from the user.

You see, I’m asking my users to enter strings such as:

split "hello world, this is a long string" -s ,
wc "this is a long string but I only want to know how many 'w' are there" -w w

But of course, Commander is by default capturing the process.argv array, which we’re not dealing with here. And even while it lets us override the input and directly give it an array of attributes, creating that array is not that simple. We’d need to parse the string and understand what part of it is a command, what part of it is a string, an attribute, etc. That sounds like a lot of parsing and a lot of work. So instead of doing it myself, I’m using the string-argv module, which let’s you do exactly that: it parses a string and turns it into an argv — like array.

So when we call the parseCommands function on line 7, we use the fake array (with 2 added values, the node command and the filename, which since it’s fake and ignored, I just went with file.js ).

And where are all the commands?

For a full review of the code, I would suggest you check out the repo here.

That said, as you saw from the previous section, all commands get loaded and parsed on the same go. This is following the Command pattern, which allows you to encapsulate the content and logic of each command into its own entity.

Thus I’ve put all my commands into individual classes inside the lib/commands folder, and I made each class extend a generic Command class that provides the generic shape for all command objects.

You can read more about that pattern in this article I wrote a while back. But the main reason why I implemented it like this is that thanks to this pattern you get:

  • The ability to add new commands or change the existing ones without affecting the rest of your code.
  • Your execution logic is simple, because the actual business logic is inside the “command capsule”.

And to exemplify this, here is the code for one of these commands, you can check out the rest directly from the repo:

The constructor sets up the information about the command, so we can feed it later to the Commander module. And the action method contains the actual business logic. That’s all you need to care and worry about with this design pattern.

Conclusion

Creating a terminal application, especially one that needs a UI is not trivial and can be a scary task for people who’ve never done it before. That said, modules like “Ink” allow you to bring the familiarity of the front-end web development into the terminal world with very little overhead.

I really enjoyed the power I was able to wield with some basic markups and two hooks. Imagine what you’d be able to do if you used the full extent of its API.

Have you seen any other library that does something like this? Share the name in the comments and I’ll check it out!

Build composable applications

Don’t build web monoliths. Use Bit to create and compose decoupled software components — in your favorite frameworks like React or Node. Build scalable and modular applications with a powerful and enjoyable dev experience.

Bring your team to Bit Cloud to host and collaborate on components together, and greatly speed up, scale, and standardize development as a team. Start with composable frontends like a Design System or Micro Frontends, or explore the composable backend. Give it a try →

Learn More


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK