The Renaissance of the Command Line
Recently, I've been falling in love with the CLI 💗.
I went through a rabbit hole of trying to make my developer experience better and found that a lot of stuff are snappier to do on the terminal. On top of that, there have been a lot of innovation surrounding the terminal.
So hopefully I can convince you to give the terminal more attention and that you find it rewarding.
Table of Contents
- Innovations in the CLI
- Why spend more time in the terminal?
- The terminal can do that?
- How does the terminal work?
- Writing a roulette terminal app using Bubbletea
Innovations
Let's talk about those innovations that I mentioned.
You might think that the terminal is a boring interface and kind of depressing to stare at all day. Well, actually, it can be glamorous 🌈
-
Charm, is a faily new startup that wrote a bunch of libraries to help us make the terminal less boring. We'll see some of those libraries later on, so no spoilers.
-
In 2016, Microsoft launched the Windows Subsystem for Linux, which lets developers run a GNU/Linux environment -- including most command-line tools. And they also launched the new Windows Terminal
-
Another new startup in the field is Warp, that are reimagining the terminal. If we go to their site, you can see the terminal looks completely different. You can edit commands like you're in VSCode with multi cursors, get "Intellisense" like autocompletion, jump between command blocks and more. You even have a "GitHub Copilot" kind of assistant for when you forget how to perform some command.
-
In fact, there's an explosion of new terminal emulators, some of them are kitty, alacritty and wezterm
Why spend more time on terminal?
- CLIs are great for telling computers exactly what to do. Unlike GUI applications, where a bunch of different workflows are hidden behing a naive looking button.
- CLIs are composoble. That's basically the UNIX philosophy. Write something that does one thing and does it well. You can them pipe one command's output to another's input to compose more complex pipelines.
- For those that care about ergonomics and staying on the keyboard for speed's sake (what I mean is you move less between the mouse and the keyboard), the terminal is a better choice.
- The shell is the closest thing to the OS, so you can easily interact with it (files, network etc.)
- Many times, GUI apps exist solely as friendlier but weaker versions of a CLI app. Knowing the CLI version gives more power with granularity.
- And as a developer you're already in the CLI a lot, so why not learn to master it.
- Large ecosystem
The problem with WYSIWYG is that it is usually equivalent to WYSIATI (What You See Is All There Is).
The terminal can do that?
Let's see some examples of stuff you probably didn't know you could do in the terminal
-
Draw
First up, you can actually draw in the terminal.
-
You can celebrate wins with some confetty!
-
You can play magic tricks!
That last example uses a library called gum. gum let's you easily create user-interactable scripts, which also look good! Here's some of the script from the last example:
bashgum choose "Yes" "No"# ...CARD=$(gum choose "Ace (A)" "Two (2)" ) # ...SUIT=$(gum choose "Hearts (♥)" "Diamonds (♦)" "Clubs (♣)" "Spades (♠)")# ...gum style --foreground 212 "You picked the $CARD of $SUIT."gum style --border rounded --padding "0 1" --margin 2 --border-foreground 1 --foreground 1 # redecho "Is this your card?"gum choose "Omg, yes!" "Nope, sorry!"The code here is quite simple. You use gum to answer the first few questions using
gum choose
. You then pick a card again withgum choose
. You then print the chosen card withgum style
to give it a nice rounded border. etc. -
Finally, you can create complex UIs - gh-dash
The GitHub CLI allows you to write extensions for it. I've written one, which is called gh-dash and I'd like to share it with you.
gh-dash
is a CLI TUI (Terminal UI) that displays a dashboard with pull requests and issues by filters you care about.It's using Charm's Bubbletea library to render the complex UI.
How does the terminal work?
Now that we know you can do a lot with the terminal, let's learn more about how all of this works.
The most basic term we need to learn about is Escape Sequences. ANSI escape sequences are a standard for signaling terminal emulators to do special things. Terminal emulators are the programs that render the user input and the commands output. They send the input to a program called the SHELL (zsh, bash etc).
The terminal interprets these sequences as commands rather than display the text as is.
Escape Sequences
Let's see an example of an Escape Sequence: \x1b[1;31m
-
The first few characters lets the terminal know that this is the start of an escape sequence. There are multiple ways to write ESC, in its octal/hex notation: Ctrl-Key
^[
, Octal\033
, Hexadecimal\x1b
, ... -
Right after that, there is a separator:
[
. -
Then the list of display attributes, each separated with a semicolumn.
Below is a table of some of the display attributes. In this example we set
1
so the text is bold, and31
so that it's red.n Name Note 0 Reset or normal All attributes off 1 Bold ... 30-37 Set foreground color ... 40-47 Set background color -
Finally,
m
represents the end of the sequence.
Colors in the terminal
The 8 color display attribute I showed earlier is a bit limiting.
We also have 256 color support for most terminals.
To use them we can write the attribute: 38;5
, then the ID (out of 256) of the color.
ANSI colors
Here's a simple python script to display all 256 colors you can run in your terminal:
import sysfor i in range(0, 16):for j in range(0, 16):code = str(i * 16 + j)sys.stdout.write(u"\u001b[48;5;" + code + "m " + code.ljust(4))print(u"\u001b[0m")
This should give your the output of:
For more info check out Haoyi's Blog Post
True color
But most newer terminals support TrueColor, strikethroughs, dotted underlines etc.
Here's a simple script to test your terminal's capabilities.
curl -L https://bit.ly/3A5B3u3 | python3 | less -R
Controling the cursor position
Using escape sequences we can also control where characters are printed.
The H
command lets us specify coordinates.
Without any arguments, (0,0) is the default - the top left corner.
Here I'm printing three 😽 at line 25, each spaced 5 columns apart.
echo -e '\033[25;25H😽\033[25;30H😽\033[25;35H😽'
This allows us to easily override existing text, to do stuff like progress bars, redraw our app's UI etc.
Writing a Roulette Bubbletea application
Now that we have a low-level understanding of how a terminal works, let's forget all that and use a bunch of libraries that make it a lot easier!
We're going to write a simple app called Roulette. The app can help you randomly choose between different items. E.g:
- What to eat for dinner
- Who's next to go in the daily
- etc...
This is how the final app looks like:
The libraries we're going to use are from Charm. To write the app, we'll learn:
- Just a bit of Go.
- The Elm Architecture used by Charm's Bubbletea lib.
- Charm's libraries: bubbletea, lipgloss and glamour.
Charm's libraries
To get ourselves familiar with Charm's libraries we can simply ssh to git.charm.sh
.
Here we can read each lib's README, see its files, commits, etc.
When we ssh into git.charm.sh
we are actually connecting to another one of Charm's libs, called Wish.
Go
Now, to use Charm's libs we need some Go knowledge. Again, we're going to use the terminal for that. For those unfamiliar, cheat.sh is a site that's built for consumption on the terminal.
If you prefer a more thorough tutorial, check out the interactive A Tour of Go.
The ELM Architecture
One last concept we need to know in order to use Charm's Bubbletea lib, is the Elm architecture. Think of it like a React's app lifecycle. Bubbletea's runtime (that's used to run our app) uses this architecture.
-
We first define a Model. This model represents our app's state. It's a simple object we define. In it, we need to store all the information we need to render our UI.
-
Given a Model, the ELM runtime calls our "View" function, which we use to return a string. Remember, terminals only display text, so that string is actually our UI!
-
Now that we know that given a Model we render a View, we need a way to change the model. That's where the Update function comes in.
-
This function recieves a message when "things happen".
Here, we write logic to update the model, depending on what happened. Messages are the way we declare what happened. User input is just one thing that might happen, but it could also be a timer firing, a network call that was returned, a file read from disk etc.
-
In addition to that, the update function can return commands. Commands are functions that bubbletea runs for us, asynchronously, that return a new message. Those messages will also eventually be sent to the update function to create our runtime loop.
-
Let's start writing the app!
Our Model
Let's first create our Model. It will hold the list of options the user passed as arguments, as well as the current tick of the rolette's rotation.
type model struct {options []stringcurrId int}
The Update function
This function does a couple of things:
- Quit the app when the user presses Ctrl+c or q
- Spin the roulette.
Once a
tickMsg
is received we:- Advance the
currId
variable - fire another one by calling
doTick
.
- Advance the
// define a type for the tickMsgtype tickMsg time.Time// Use bubbletea's utility Tick function to make sure// tickMsgs are fired with a consistent interval of 80ms.func doTick() tea.Cmd {return tea.Tick(time.Millisecond*80, func(t time.Time) tea.Msg {return tickMsg(t)})}// Our update functionfunc (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 tickMsg:m.currId = (m.currId + 1) % len(m.options)return m, doTick()}return m, nil}
Starting up the roulette's rotation
In order to start rotating currId
, bubbletea provides us with an Init
function which it will call before the first update.
We'll use that to fire our first tickMsg
.
func (m Model) Init() tea.Cmd {return doTick()}
The View function
Here we're returning the string that represents our UI.
- We render the options using lipgloss styles (more on those later)
- We put each option in the
renderedOptions
array with an offset, depending oncurrId
. This will make our options rotate like in a roulette.
func (m Model) View() string {s := strings.Builder{}renderedOptions := make([]string, len(m.options))for i := 0; i < len(m.options); i++ {option := m.options[(i+m.currId)%len(m.options)]var style lipgloss.Styleif i == len(m.options)/2 {style = selectedItemStyle} else {style = itemStyle}renderedOptions[i] = style.Render(option)}s.WriteString(lipgloss.JoinVertical(lipgloss.Center, renderedOptions...))s.WriteString("\n")return s.String()}
Styling
For styling, we'll use lipgloss.
var (itemStyle = lipgloss.NewStyle().Padding(0, 5).Width(20).Height(1).Align(lipgloss.Center)selectedItemStyle = itemStyle.Copy().Background(lipgloss.Color("1")). // redForeground(lipgloss.Color("0")). // blackBold(true))
The full code
You can view the full code at my GitHub: https://github.com/dlvhdr/roulette/blob/tutorial/03-finished/roulette.go
Taking it to the next level
With some more styling, and by adding physics using Charm's Harmonica lib, we can get something like this:
View the source code for this more advanced version in dlvhdr/roulette.
Reources
- Charm
- ANSI escape codes
- Bubbletea
- confetty
- gum
- slides - used to power this presentation
- gh - the GitHub CLI
- gh-dash - my GitHub dashboard extension
- My blog