Skip to content

1.5 Your First TUI Program

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 Load ██████░░░░ 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';
import os from 'os';
// ── 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 (hardcoded for now — see Challenge below)
const metrics = [
{ label: 'CPU Load', 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} `;
// visible width: 2 + 10 + 2 + 10 + 2 + 4 + 2 = 32; pad to W
write(border('') + row + ' '.repeat(W - 32) + 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);
// prefix visible width: 2 + 10 + 2 = 14; pad remainder to W
const valueVisible = value.replace(/\x1b\[[0-9;]*m/g, '').length;
const pad = ' '.repeat(Math.max(0, W - 14 - valueVisible));
write(border('') + ` ${chalk.blue(label.padEnd(10))} ${value}${pad}` + border(''));
});
// bottom
moveTo(1, 10);
write(border('' + ''.repeat(W) + ''));
moveTo(1, 12);
write(chalk.dim(' Press Ctrl+C to exit.\n'));
}
// ── main ───────────────────────────────────────────────────
process.stdout.write(ansiEscapes.enterAlternativeScreen);
process.stdout.write(ansiEscapes.cursorHide);
process.stdout.write(ansiEscapes.clearScreen);
function cleanup() {
process.stdout.write(ansiEscapes.cursorShow);
process.stdout.write(ansiEscapes.exitAlternativeScreen);
// No process.exit() — no timers or listeners remain after this,
// so Node exits naturally once stdout is fully flushed.
}
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.

1. Color thresholds

Change the bar and percentage color based on the value: green below 60%, yellow from 60–80%, red above 80%. The metric values are hardcoded, so try setting one to 85 to trigger it.

This will likely break your alignment the first time you try it — the ANSI invisible-byte trap from the note above is exactly what you’ll hit. Fix it the right way.

2. Center the box

Right now the box always draws at column 1, row 1. Use process.stdout.columns and process.stdout.rows to calculate a starting position that places the box in the center of the terminal. Resize your terminal window and verify it stays centered.

Bonus: replace the hardcoded metric values with real ones from os.loadavg(), os.totalmem(), os.freemem(), and os.uptime(), then wrap render() in setInterval(render, 1000) to make the dashboard live. The completed version is in examples/module-1/05-first-program.ts.


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.