Control the terminal with ANSI escape codes

Terminals interpret special byte sequences, called ANSI escape codes, as commands: change the text color, erase a line, move or hide the cursor. Writing them directly is all it takes to build colored output and self-updating status lines without any dependencies.

Edit on GitHub

import { green } from "jsr:@ende/fmt@^2026.10.15/colors";
import { delay } from "jsr:@ende/async@^2026.10.15/delay";

Escape sequences start with the escape byte, written \x1b, followed by [ and a command. Writing raw bytes to stdout keeps full control over when a newline is emitted.

const encoder = new TextEncoder();

function write(text: string) {
  Ende.stdout.writeSync(encoder.encode(text));
}

Colors and text styles are select graphic rendition commands. The sequence \x1b[31m switches to red and \x1b[0m resets all styling. Always reset, otherwise the style leaks into the next line of output.

console.log("\x1b[31mred\x1b[0m \x1b[1mbold\x1b[0m \x1b[4munderline\x1b[0m");

For colors alone you rarely need raw codes. The standard library wraps them in plain functions that emit the same bytes:

console.log(green("green via @ende/fmt/colors"));

Now the classic trick behind installers and download counters: a single status line that rewrites itself. The sequence \x1b[2K erases the whole current line and \r returns the cursor to column zero, so the next write replaces the line instead of appending. \x1b[?25l hides the cursor while the line is being redrawn and \x1b[?25h shows it again. Restore it in a finally block, otherwise an early exception leaves the terminal without a cursor. Related codes move the cursor instead of erasing: \x1b[1A goes up one line and \x1b[1B goes down.

const steps = ["Resolving packages", "Downloading", "Linking", "Cleaning up"];

Redrawing a line only works on an interactive terminal. When output is piped, print ordinary lines instead.

if (Ende.stdout.isTerminal()) {
  write("\x1b[?25l");

  try {
    for (let i = 0; i < steps.length; i++) {
      write(`\x1b[2K\r[${i + 1}/${steps.length}] ${steps[i]}...`);
      await delay(600);
    }

    write("\x1b[2K\rInstall complete.\n");
  } finally {
    write("\x1b[?25h");
  }
} else {
  for (const step of steps) {
    console.log(`${step}...`);
    await delay(100);
  }

  console.log("Install complete.");
}

In a terminal the four steps flash by on one line, which then settles on the final text: Install complete.

When piped, the fallback prints each step on its own line: ende run ansi_terminal.ts | cat red bold underline green via @ende/fmt/colors Resolving packages... Downloading... Linking... Cleaning up... Install complete.

Run this example locally using the Ende CLI:

ende run https://docs.ende.sh/examples/scripts/ansi_terminal.ts

Additional resources