Skip to content

1.5 Your First TUI Program

import { Aside } from ‘@astrojs/starlight/components’;

Time to write code. In this lesson you will build a static TUI dashboard — no interactivity yet, but it will cover everything from Module 1: alternate screen, cursor positioning, colors, and box-drawing.

Here is the target:

╔══════════════════════════════════╗
║ System Dashboard ║
╠══════════════════════════════════╣
║ CPU Usage ██████░░░░░ 62% ║
║ Memory ████████░░ 80% ║
║ Disk ████░░░░░░ 40% ║
╠══════════════════════════════════╣
║ Status ● Online ║
║ Uptime 3d 14h 22m ║
╚══════════════════════════════════╝

Box-drawing characters are part of Unicode. They live in the range U+2500–U+257F. You do not need to memorize them — just keep a reference:

CharNameUse
Light Horizontaltop/bottom border, single line
Light Verticalside border
Light cornerssingle-line box corners
Double Horizontaldouble-line top/bottom
Double Verticaldouble-line sides
Double cornersdouble-line box
Double vertical+right/leftdivider (double-line)
Double horizontal+down/updivider
Light crossintersection
Block elementsprogress bars, graphs
Light shadeempty portion of progress bar
Bulletstatus indicators

Create examples/module-1/05-first-program.ts:

import chalk from 'chalk';
import ansiEscapes from 'ansi-escapes';
// ── helpers ────────────────────────────────────────────────
function moveTo(col: number, row: number) {
process.stdout.write(ansiEscapes.cursorTo(col - 1, row - 1));
}
function write(text: string) {
process.stdout.write(text);
}
// ── rendering ──────────────────────────────────────────────
function renderBar(value: number, max: number, width: number): string {
const filled = Math.round((value / max) * width);
const empty = width - filled;
return chalk.green(''.repeat(filled)) + chalk.dim(''.repeat(empty));
}
function render() {
const W = 38; // inner width
const border = chalk.cyan;
// top
moveTo(1, 1);
write(border('' + ''.repeat(W) + ''));
// title
moveTo(1, 2);
const title = 'System Dashboard';
const padding = Math.floor((W - title.length) / 2);
write(border('') + ' '.repeat(padding) + chalk.bold.white(title) + ' '.repeat(W - padding - title.length) + border(''));
// divider
moveTo(1, 3);
write(border('' + ''.repeat(W) + ''));
// metrics
const metrics = [
{ label: 'CPU Usage', value: 62, max: 100 },
{ label: 'Memory', value: 80, max: 100 },
{ label: 'Disk', value: 40, max: 100 },
];
metrics.forEach(({ label, value, max }, i) => {
moveTo(1, 4 + i);
const bar = renderBar(value, max, 10);
const pct = chalk.yellow(`${value}%`.padStart(4));
const row = ` ${chalk.blue(label.padEnd(10))} ${bar} ${pct} `;
write(border('') + row.padEnd(W) + border(''));
});
// divider
moveTo(1, 7);
write(border('' + ''.repeat(W) + ''));
// status rows
const status = [
{ label: 'Status', value: chalk.green('● Online') },
{ label: 'Uptime', value: chalk.white('3d 14h 22m') },
];
status.forEach(({ label, value }, i) => {
moveTo(1, 8 + i);
const row = ` ${chalk.blue(label.padEnd(10))} ${value} `;
write(border('') + row + ' '.repeat(Math.max(0, W - 2 - label.length - 2 - value.replace(/\x1b\[[0-9;]*m/g, '').length - 2)) + border(''));
});
// bottom
moveTo(1, 10);
write(border('' + ''.repeat(W) + ''));
// move cursor below the box
moveTo(1, 12);
write(chalk.dim(' Press Ctrl+C to exit.\n'));
}
// ── main ───────────────────────────────────────────────────
// Enter alternate screen and hide cursor
process.stdout.write(ansiEscapes.enterAlternativeScreen);
process.stdout.write(ansiEscapes.cursorHide);
process.stdout.write(ansiEscapes.clearScreen);
// Make sure we always restore the terminal on exit
function cleanup() {
process.stdout.write(ansiEscapes.cursorShow);
process.stdout.write(ansiEscapes.exitAlternativeScreen);
process.exit(0);
}
process.on('SIGINT', cleanup); // Ctrl+C
process.on('SIGTERM', cleanup); // kill
render();

Run it:

Terminal window
cd examples && npx tsx module-1/05-first-program.ts

Alternate screen: enterAlternativeScreen switches to a clean buffer. When the program exits (or you press Ctrl+C), exitAlternativeScreen restores the original terminal. This is why your shell history is not overwritten.

cursorHide / cursorShow: We hide the cursor during rendering. If we left it visible, it would flash across the screen as we position it for each draw call. We show it again in the cleanup handler.

ansiEscapes.cursorTo(col, row): ansiEscapes uses 0-indexed coordinates. Our moveTo helper adds 1 to column and row so we can think in 1-indexed terms (column 1 = leftmost).

Cleanup on signals: The SIGINT listener catches Ctrl+C. Without it, Ctrl+C would kill the process immediately — leaving the alternate screen active and the cursor hidden. The user’s terminal would look broken. Always restore terminal state on exit.

padEnd: We use String.padEnd(width) to fill each row to exactly the right width, so the right border aligns perfectly.

Extend the dashboard with a real value: replace the hardcoded 62 for CPU with an actual measurement using Node’s os module:

import os from 'os';
// os.loadavg() returns [1min, 5min, 15min] load averages
const [load1] = os.loadavg();
const cpuPercent = Math.min(100, Math.round(load1 * 25)); // rough approximation

Then make it refresh every second by wrapping render() in setInterval.


Module 1 complete. You now understand how terminals work, what ANSI escape codes are, how to use colors and styles, and how to position content anywhere on screen.

Module 2 adds the missing piece: keyboard input and a real-time render loop.