CLIs with updating statuses

Different methods for updating statuses inside a CLI

I still often prefer Command-Line Interfaces (CLIs) over GUIs. I will distinguish between a CLI and a Textual User Interface (TUI). A true CLI takes all of its input as command-line arguments and possibly stdin. But if it does read from stdin, that input should be interpreted as a single block, rather than as responses to stdout/stderr. CLIs are stateless, as opposed to TUIs and GUIs that interpret input depending on their state at the time input is received. You can copy/paste a command-line and get (mostly) the same behavior, without having to memorize which elements of the GUI to click on.

Traditionally, diagnostic information, like progress updates, are written to stderr. Actual results are written to stdout. The diagnostics of the simplest CLIs consist of purely visible characters, which present a tradeoff: With what frequency should progress updates be written? Infrequent updates force the user to wait. Frequent updates create a wall of unimportant messages that potentially obscure more important warnings and errors.

I prefer CLIs that continuously update their status. The way to implement such updates is rather interesting. Proper TUIs accomplish status updates by maintaining a buffer and synchronizing the terminal display to this buffer, but doing so conflicts with the terminal's scrollback. You can't have status updates in the right place as the user is scrolling back. Terminal APIs allow you to write to particular locations on screen, but you can't read what was previously in those locations. You can't detect scrolling. You can't write to particular locations within the scrollback buffer.

The mechanism for CLIs with updating statuses dates back at least 150 years to the first commercially successful typewriter - the Sholes and Glidden typewriter - which used a carriage return lever to move the carriage back to the left side of the paper after typing a line of text. Moving to the left without moving down was used to write text on-top of existing text, allowing for special effects like strikethrough, underline, or bold. When carriage return was adapted to teletype machines it replaced existing characters rather than writing on-top of them, and this behavior carries forward to TTY terminals (and emulators) today.

This carriage-return replacement forms the basis for CLIs to provide updating statuses, but these days it's slightly easier with ANSI escape codes. With carriage-returns you need to print spaces to erase each previously printed character. If you don't know how many characters were previously printed you can query the terminal size with the ioctl TIOCGWINSZ, take the width, and then print that many spaces. With ANSI escape codes you can just print "\033[2K" to clear the entire line. To clear multiple lines you can repeatedly move the cursor up with "\033[F" and then clear each line.

A common problem when implementing updating statuses is you don't know if something else has written to the terminal since you last updated the status. If you blindly attempt to erase the last status you may instead erase an important error/warning written to the console. It'd be quite convenient if you could query the characters in the terminal, but unfortunately this isn't possible (as far as I can tell). TUI-style libraries, such as curses, emulate this by maintaining a buffer of the desired terminal contents and repeatedly synchronizing it to the actual terminal.

I reason that the inability to query a character at a particular position is the textual equivalent of the GPU readback problem. In the graphics world, attempting to read a pixel output from the GPU requires waiting for the GPU pipeline to flush, which introduces high latency. In the text world, your terminal might be on a different computer, so reading a character at a particular position would require a network roundtrip.

It's quite interesting that you can successfully read the terminal size. This doesn't require a network roundtrip because whenever the size of the terminal changes the ssh client detects the change via SIGWINCH and then forwards it to the sshd server which communicates it to the kernel with TIOCSWINSZ. This works, but imperfectly. You may write characters to the terminal before you find out that the terminal size has just changed. Usually this lack of true synchronization creates some minor artifacts - maybe the status is displayed incorrectly until the server receives the window size update and issues a new status update.

Could similar tricks be played to keep the server informed about the contents of the entire scrollback buffer? It'd be far more complex - maybe to the point of being impractical. The sshd server would need to understand exactly how the terminal interpreted each escape code.

In the absence of readback, how can you ensure that nothing else writes to the terminal in between status updates? In some sense, this is truly impossible. Your program may have been invoked from within another program that is also writing to the terminal simultaneously. But this is arguably a bug in the calling program, so we'll set this aside.

In small scripts you can carefully avoid writing to the terminal. In larger scripts you can redirect output to prevent accidentally writing to the terminal. With a language like Python you can temporarily set sys.stdout/sys.stderr to different objects satisfying the TextIO interface. This will catch most accidental writes, but it's still possible to write to stdout/stderr directly with file descriptors 1/2. A more complete redirection is possible at the file descriptor level with dup2. File descriptors are handles, so they operate much like pointers. Conceptually dup2 allows you to "dereference" the file descriptor and "point" it somewhere else.

These mechanisms mostly work, but expose edge cases and require careful coding to avoid nasty bugs. If you have some code that expects stderr to be connected to a TTY it may cause issues when you temporarily redirect stderr. And if you don't properly restore stderr you won't be able to see the traceback normally printed when your program crashes. This is a nasty bug because you may not even realize there is a problem.

I think the best solution is to consolidate status updates to a tiny process that parses output from other processes. If those other processes wish to issue status updates, they can use a special line prefix. This approach means other processes see stdout/stderr as being disconnected from a terminal, but I'd argue this is preferable since the root issue stems from multiple programs attempting to interact with a terminal simultaneously.


If you enjoyed this post, please let me know on Twitter or Bluesky.

Posted December 22, 2024.

Tags: #cli