DEV Community

Cover image for CLI application with the Node.js Readline module (2/3)

CLI application with the Node.js Readline module (2/3)

Node.js version: 22.1.0.

Part one - the basics - is here: https://dev.to/camptocamp-geo/cli-application-with-the-nodejs-readline-module-48ic

In the part one, I was able to go far using the question and the listener on('line', cb) methods from the Readline interface.

Going further

I currently have two limitations:

  • I can't display anything below the input line.
  • It's impossible to completely refresh the interface.

To compensate for this, I can use many of the commands and events from stdin, readline and Readline.Interface. I'm going to detail here the three major problems I encountered.

Clear the screen

If you want to build a complete CLI application, you probably want to be able to refresh your entire terminal to avoid polluting the screen with old questions and commands.

import readline, { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';

const rl = createInterface({
  input: stdin,
  output: stdout,
});

const clear = () => {
  readline.cursorTo(stdout, 0, 0);
  readline.clearScreenDown(stdout);
};

let value = 0;
const printQuestion = () => console.log(`Add up! Add a number to ${value}:`);

const refresh = () => {
  clear();
  printQuestion();
}

const onLine = (line) => {
  const val = parseFloat(line);
  if (isNaN(val)) {
    console.log("Please, add a valide number")
    return;
  }
  value += val;
  refresh();
};

rl.on("line", onLine)
refresh();
Enter fullscreen mode Exit fullscreen mode

The main innovation here is the direct import of readline and the use of readline.cursorTo and readline.clearScreenDown.

The first method moves the cursor to a given absolute position (column, row). Use readline.moveCursor for a relative position. The second deletes all characters after the cursor position.

This only works in a TTY (Text Terminal), which is our case with stdout and a direct file call from node.js.

Refresh or write after input

At one point, I wanted to add a time limit to my game. And I wanted to display the remaining time. So I added something like this (you can add it at the end of the previous script):

setInterval(() => {
  refresh();
}, 1000)
Enter fullscreen mode Exit fullscreen mode

What's the problem? Well, the implemented clear function deletes everything. The error text is not in the refresh function, and will be deleted. And the text currently being written will also be deleted.

Please note that this is only visual. The line being written still exists and can be accessed with the line attribute of an instance of readline.Interface.

One solution is to be precise with readline.clearline. But this solution can quickly become unwieldy and doesn't solve a second complication: writing after the input line.

So I prefer to prevent stdin from displaying input characters, and display these characters myself, wherever I want.

import readline, { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';


const rl = createInterface({
  input: stdin,
  output: stdout,
});

const clear = () => {
  readline.cursorTo(stdout, 0, 0);
  readline.clearScreenDown(stdout);
};

// To manage the text to print, store it into a map.
const content = new Map();
let value = 0;

const updateQuestion = () => {
 content.set("question", `Make additions! Add a number to ${value}:`);
};

// Print the whole stored text content.
const printContent = () => content.forEach(text => console.log(text));

// Print the line as well as it's deleted by the clear.
// Normally we have a blinking caret to indicate the position
// Simulate it as well (psoition and representation).
let blinkWhite = true;
const txtbgWhite = "\x1b[47m";
const txtReset = "\x1b[0m";
const printInputLine = () => {
  const caret = blinkWhite ? `${txtbgWhite} ${txtReset}` : " ";
  const chars = rl.line.split("");
  chars.splice(rl.cursor, 0, caret);
  console.log(chars.join(""));
};

const refresh = () => {
  clear();
  printContent();
  printInputLine();
}

const onLine = (line) => {
  const val = parseFloat(line);
  if (isNaN(val)) {
    content.set("error", "Please, add a valide number");
    return;
  }
  content.delete("error");
  value += val;
  updateQuestion();
  refresh();
};

// onListen to keypress to clear the line and handle it by ourselves.
// As we manage the blinking caret, we also have to refresh on cursor move.
const onKeypress = (letter, key) => {
  if (!letter && key.name !== "left" && key.name !== "right") {
    return;
  }
  // Clear the input line
  readline.clearLine(stdout, 0);
  refresh();

};
stdin.on("keypress", onKeypress);
// To unlisten
// stdin.off("keypress", onKeypress);

rl.on("line", onLine)
updateQuestion();
refresh();

setInterval(() => {
  blinkWhite = !blinkWhite;
  refresh();
}, 500)
Enter fullscreen mode Exit fullscreen mode

We need to be able to display the input text ourselves. The “printInputLine” function takes care of this and also simulates the “blinking caret” essential for the user to edit the line. There's no need to store what's written. “Rll.line”, from readline.Interface, takes care of this.

The final step is to hide the default write line. To do this, you can listen to the "keypress" event on stdin directly. If a letter is pressed, or the user navigates to the left or right of the text, the text line is deleted, and the text updated.

stdin.on(“keypress”, callback) is also very useful for simply navigating through a menu. For example to select an option from a list of options, as shown here: https://github.com/ger-benjamin/cli-learning-cards/blob/1.1.0/src/scenes/list-select.ts

And the default blinking caret?

In the example above, we're simulating the “blinking caret”, or cursor, but there's nothing to stop your terminal's default caret from blinking. With the current implementation, we have two blinking caret, which is confusing for the user.

It is possible to simulate inputs by the user. For example (taken directly from the Node.js documentation):

rl.write('Delete this!');
// Simulate Ctrl+U to delete the line written previously
rl.write(null, { ctrl: true, name: 'u' });
Enter fullscreen mode Exit fullscreen mode

It's just like pressing “ctrl” + u in a terminal.

Here, rl is a “readline.Interface”. It's possible to use stdout.write directly, but the “ctrl + u” combination will have to be written directly into its ANSI code.

I didn't find (and didn't look too hard for) the keyboard combination that would allow me to hide the “blinking caret”. But I did find its ANSI combination. Executing process.stdout.write(”\x1B[?25l“); hides the cursor.

This is a combination directly dedicated to the terminal. At this point, you might be able to understand for yourself how the “readline.clearLine” or “readline.cursorTo” functions are working.

As hiding the cursor directly affects your terminal, don't forget to make it appear again at the end of your Node.js process. Even in case of error! To do this, make sure your program always executes the command process.stdout.write(“\x1B[?25h”);, in order to display the cursor again.


Part three - advanced notes - is here: (coming end of March 2025)

AWS GenAI LIVE image

How is generative AI increasing efficiency?

Join AWS GenAI LIVE! to find out how gen AI is reshaping productivity, streamlining processes, and driving innovation.

Learn more

Top comments (0)

Qodo Takeover

Introducing Qodo Gen 1.0: Transform Your Workflow with Agentic AI

Rather than just generating snippets, our agents understand your entire project context, can make decisions, use tools, and carry out tasks autonomously.

Read full post

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay