closeout
Guides

The terminal

hideCursor, rawMode and alternateScreen: change the terminal and register the undo in one call, so a program that dies by any door — even mid-shutdown — hands back a usable terminal.

A program that hides the cursor, switches the terminal to raw mode or enters the alternate screen has to put each back before it goes, on every way out. Forget one on Ctrl-C and the user's shell is left without a cursor, not echoing what they type, or showing a screen that is not their scrollback.

closeout makes each change and registers its undo in the same call:

import { alternateScreen, hideCursor, rawMode } from 'closeout';

const undo = [rawMode(process.stdin), alternateScreen(process.stdout), hideCursor(process.stdout)];
try {
  await runTheFullScreenApp();
} finally {
  for (const back of undo) back();
}
calldoes nowundoes at exit, in restore
hideCursor(stream)hides the cursorshows it
rawMode(input)turns raw mode onturns it off
alternateScreen(stream)enters the alternate screenleaves it
showCursor(stream)shows the cursor now—

Each returns the function that undoes it now and unregisters the exit undo, so a program that cleans up after itself leaves nothing for exit to do. Each undo runs once, whoever asks first — the program's finally, or the shutdown.

Every door, last

The undos go in the restore phase, which runs after every flush and release handler. So the terminal comes back:

  • on every exit path: exit, beforeExit, every signal, a throw, a rejection;
  • after a handler that threw, because one handler's failure is its own;
  • after a handler that hung, because the deadline stops waiting but still runs restore;
  • after a second signal: a second Ctrl-C, or a SIGTERM behind it, while a handler still holds the first shutdown, waits for that shutdown instead of killing the process before the terminal is restored.

terminal-restore.test.ts checks each of those on a real process: it spawns a child that enters raw mode and the alternate screen and hides the cursor, ends it by that path, and reads back the escape sequences and mode changes it actually made.

Only what closeout turned on

rawMode(input) turns off only what it turned on. If the input is already raw, somebody else owns that state: the call changes nothing, now or at exit.

Nothing into a pipe

A stream that is not a terminal gets no escape sequence, in either direction, and an input that is not a terminal gets no mode change. Escape sequences in a pipe corrupt the output the pipe exists to carry.

draw.mjs
import { alternateScreen, hideCursor, rawMode } from 'closeout';

rawMode(process.stdin);
alternateScreen(process.stdout);
hideCursor(process.stdout);
console.log('drawing');
process.exit(0);

Piped, the only bytes out are the program's own:

node draw.mjs
drawing

Registering on your own instance

The three calls register on the process-wide instance. alternateScreen and rawMode take the object install() returned as a second argument, to register on that one instead; the object's own hideCursor method does the same for the cursor:

import { alternateScreen, install } from 'closeout';

const closeout = install({ deadline: 5000 });
closeout.hideCursor(process.stdout);
alternateScreen(process.stdout, closeout);

closeout/cursor is the same four functions without a registry: each takes, as its second argument, the function that registers its undo — (undo) => unregister — so a renderer with its own shutdown bookkeeping can use them without closeout's. It also exports the sequences: HIDE_CURSOR, SHOW_CURSOR, ENTER_ALTERNATE_SCREEN and LEAVE_ALTERNATE_SCREEN.

What is tested

  • restore.test.ts: every door leaves the terminal as it was found, last; a handler that threw or a deadline that fired does not keep it; nothing is undone twice; raw mode somebody else turned on stays on.
  • terminal-restore.test.ts: the same on a real process, including the second signal.
  • cursor.test.ts: the pairing, the idempotence and the pipe.

On this page