Learnings from Creating a CLI Library

I just released Terminalia, a library for beautiful console output in your Laravel app. Here's what I learned along the way.

Joe Tannenbaum

Let me preface all of this by saying: writing Laravel Artisan commands is a great experience.

It's the DX I want, it's really easy to whip up a robust CLI app (especially when using Laravel Zero, tailor-made for the purpose). I was able to build Bellows in a reasonable amount of time simply because of all of the utility Laravel provides out of the box for CLI apps.

Then I came across Clack, a CLI library for Node.js by Nate Moore and saw what the UX could be. And, frankly, I got jealous. So I channeled that jealousy into meticulously re-creating Clack for Laravel, and the result is Terminalia.

Here's what I love about the resulting package:

  • The look and feel is cohesive across prompts and output

  • The styling serves almost as a timeline to orient the user: "You were here, you did this, now you're here"

  • The final output is more compact. Once you've responded to a prompt, only your answer shows underneath the question.

  • Inline validation using Laravel's existing validator. This one is the biggie, in my opinion.

  • A spinner for showing processes that have an indeterminate running time

Here's what it looks like:

Oh, hello 😍
Oh, hello 😍

What I Learned from Building It

These are some very CLI-specific learnings, but they may come in useful for somebody else building their own library.

Kick the Flick(er)

This is a big one, something I ran into over and over and still exists in some spots.

When you're building interactive apps on the command line, you're basically suppressing normal input and re-writing a series of lines, over and over again. You're telling the terminal, "Ok, move the cursor here, clear out whatever is in this bit, now write this". Except you're doing that, in my case, a lot.

For example, controlling the focused item in a list of choice by letting the user press the arrow keys:

You're listening for the keypress, then re-writing the list with the correct item visually focused. Now layer in filtering:

And you're potentially re-rending on virtually every keypress. If you're doing anything even remotely "heavy" processing-wise between the clearing and the re-writing of the output, you get a flicker and it doesn't look smooth. If you try and hold off the clearing of the output to prevent flicker, then you get a perceptible delay in interaction which is arguably worse.

The Take Away: Keep your logic as light and tight as possible when re-writing lines in the terminal.

Cursor Tracking

"Where is the cursor now?"

If you're writing a CLI library, this is something you'll ask yourself with some frequency. I thought I was being clever by tracking the cursor internally, when I wrote a line I would just keep track of how many lines were written so I knew how far I would have to go back up in order to re-write the line if I had to.

But when developing certain features, that fell apart very quickly. For example, when layering in the ability to filter choices, I suddenly had lines like this all over the place:

1$this->cursor->moveUp($this->filtering ? 2 : 1);

This wasn't a code smell, it was a code stink. Every time I wrote it my heart hurt.

The answer: don't use relative positioning. When you know that you are going to need to move the cursor back to a specific spot in the terminal at a later point, save that spot by its coordinates and label it. I called it "bookmarking":

1$this->bookmark('searchInput');
2// Then somewhere else in the script:
3$this->moveToBookmark('searchInput');

The Take Away: I found absolute vs relative position tracking of the cursor to make my brain hurt less.

Side Note: Thank goodness for the Symfony Cursor helper, which makes this whole thing much simpler to suss out regardless of how you want to tackle it.

What I Like About the Resulting Package

Keypress/Terminal Event Listeners

I like the way I implemented this. It's really easy to reason about and makes adding new features based on hotkeys a breeze. Within a component you just add a trait and listen for stuff:

1<?php
2 ...
3namespace Terminalia\PromptTypes;
4 
5use Terminalia\Enums\ControlSequence;
6use Terminalia\Enums\TerminalEvent;
7use Terminalia\Helpers\ListensForInput;
8 
9class Choices
10{
11 use ListensForInput;
12 
13 protected function prompt()
14 {
15 $listener = $this->inputListener();
16 
17 $listener->on('*', function (string $text) {
18 $this->setQuery($this->query . $text);
19 });
20 
21 $listener->on(
22 TerminalEvent::ESCAPE,
23 function () {
24 $this->filtering = false;
25 }
26 );
27 
28 $listener->on(ControlSequence::BACKSPACE, function () {
29 $this->setQuery(substr($this->query, 0, -1));
30 });
31 
32 $listener->on('/', function () {
33 $this->filtering = true;
34 $this->setQuery('');
35 });
36 
37 $listener->on(
38 [ControlSequence::UP, ControlSequence::LEFT],
39 function () {
40 $this->setRelativeFocusedIndex(-1);
41 }
42 );
43 
44 $listener->on(
45 [ControlSequence::DOWN, ControlSequence::RIGHT],
46 function () {
47 $this->setRelativeFocusedIndex(1);
48 }
49 );
50 
51 $listener->on(' ', function () {
52 $this->setSelected();
53 });
54 
55 $listener->on(TerminalEvent::EXIT, function () {
56 $this->cursor->show();
57 });
58 
59 // Action to run every time a key is pressed
60 $listener->afterKeyPress($this->writeChoices(...));
61 }
62}

Spinner

I'm just super happy with the way the spinner component turned out. The flexibility of it, the ability to communicate to the user what is happening while a process is running is just *chef's kiss*.

Choice Filtering

This was the hardest thing to write in the whole package, but I'm satisfied with the way it turned out. I think there's more to do here, but for now, pretty solid.

Room for Improvement

Mixin

I wish the package weren't ultimately a console mixin, it bothers me that the methods are termAsk and termChoice instead of just ask and choice. I started by trying to override the styling of the existing Laravel methods, but I felt like:

a) I was hijacking too many components and it was starting to feel wonky
b) I think it would be jarring to install a package and suddenly all of your Artisan methods were overriden
c) I wanted to include functionality and arguments that weren't compatible with the existing methods

Input

There are some instances when typing were you realize that you aren't actually just typing into the terminal, that the script is doing some work for you. For example, if you are typing and want to move the cursor to the left to edit something without deleting it, right now that won't happen. Something I want to address to get a more natural and expected user experience.

Off You Go

Give it a whirl! Kick the tires! Try Terminalia out and see how you like it: https://github.com/joetannenbaum/terminalia

(Excellent) syntax highlighting provided by Torchlight