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
Section titled “Box-Drawing Characters”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:
| Char | Name | Use |
|---|---|---|
─ | Light Horizontal | top/bottom border, single line |
│ | Light Vertical | side border |
┌ ┐ └ ┘ | Light corners | single-line box corners |
═ | Double Horizontal | double-line top/bottom |
║ | Double Vertical | double-line sides |
╔ ╗ ╚ ╝ | Double corners | double-line box |
╠ ╣ | Double vertical+right/left | divider (double-line) |
╦ ╩ | Double horizontal+down/up | divider |
┼ | Light cross | intersection |
▓ █ | Block elements | progress bars, graphs |
░ | Light shade | empty portion of progress bar |
● | Bullet | status indicators |
The Program
Section titled “The Program”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 cursorprocess.stdout.write(ansiEscapes.enterAlternativeScreen);process.stdout.write(ansiEscapes.cursorHide);process.stdout.write(ansiEscapes.clearScreen);
// Make sure we always restore the terminal on exitfunction cleanup() { process.stdout.write(ansiEscapes.cursorShow); process.stdout.write(ansiEscapes.exitAlternativeScreen); process.exit(0);}
process.on('SIGINT', cleanup); // Ctrl+Cprocess.on('SIGTERM', cleanup); // kill
render();Run it:
cd examples && npx tsx module-1/05-first-program.tsWhat’s Happening
Section titled “What’s Happening”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.
Challenge
Section titled “Challenge”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 averagesconst [load1] = os.loadavg();const cpuPercent = Math.min(100, Math.round(load1 * 25)); // rough approximationThen 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.