2.4 Handling Terminal Resize
Every TUI that runs for more than a few seconds will be resized. A program that ignores resize either looks broken (content outside the new bounds) or crashes (writing beyond the terminal edge). Handling it correctly takes about 10 lines of code.
The Resize Signal
Section titled “The Resize Signal”When the user resizes the terminal window, the OS sends SIGWINCH (SIGnal WINdow CHange) to the foreground process. Node.js exposes this as a 'resize' event on process.stdout:
process.stdout.on('resize', () => { const cols = process.stdout.columns; const rows = process.stdout.rows; console.error(`Resized to ${cols}x${rows}`); render(); // full re-render with new dimensions});process.stdout.columns and process.stdout.rows always reflect the current terminal size — they update before the 'resize' event fires.
What Can Go Wrong Without Resize Handling
Section titled “What Can Go Wrong Without Resize Handling”| Problem | Cause |
|---|---|
| Content cut off | Layout computed once at startup with old dimensions |
| Borders misaligned | Column count cached, used for drawing box edges |
| Crash on width/index | Accessing columns[col] where col >= newCols |
| Scrollback garbage | Content written past the new terminal height |
The Fix: Never Cache Dimensions
Section titled “The Fix: Never Cache Dimensions”The key rule: always read process.stdout.columns and process.stdout.rows at the start of every render() call. Never store them in a variable that outlives a single frame.
// ❌ Bad: cached at startup, stale after resizeconst COLS = process.stdout.columns;const ROWS = process.stdout.rows;
function render() { const box = '─'.repeat(COLS - 2); // wrong after resize}
// ✅ Good: read fresh every framefunction render() { const cols = process.stdout.columns; const rows = process.stdout.rows; const box = '─'.repeat(cols - 2); // always correct}A Resize-Aware Template
Section titled “A Resize-Aware Template”Here is a complete program that handles resize gracefully:
import chalk from 'chalk';import ansiEscapes from 'ansi-escapes';import { parseKey } from './01-keypress-parser';
type State = { selectedIndex: number; items: string[];};
const state: State = { selectedIndex: 0, items: ['Option A', 'Option B', 'Option C', 'Option D', 'Option E'],};
function render() { const cols = process.stdout.columns; // fresh every render const rows = process.stdout.rows;
process.stdout.write(ansiEscapes.cursorTo(0, 0));
// ── title bar const title = ' Resize Demo '; const titlePad = Math.floor((cols - title.length) / 2); process.stdout.write( chalk.bgCyan.black(' '.repeat(titlePad) + title + ' '.repeat(cols - titlePad - title.length)) + '\n' );
// ── size indicator process.stdout.write(chalk.dim(` Terminal: ${cols}×${rows}\n`)); process.stdout.write(chalk.dim('─'.repeat(cols) + '\n'));
// ── menu items (fit within available rows) const availableRows = rows - 5; // title(1) + size(1) + divider(1) + footer(2) const visibleItems = state.items.slice(0, availableRows);
for (let i = 0; i < visibleItems.length; i++) { const isSelected = i === state.selectedIndex; const prefix = isSelected ? '▶ ' : ' '; const line = `${prefix}${visibleItems[i]}`.padEnd(cols); process.stdout.write( isSelected ? chalk.bgBlue.white(line) : chalk.white(line) ); process.stdout.write('\n'); }
// ── footer process.stdout.write(ansiEscapes.cursorTo(0, rows - 2)); process.stdout.write(chalk.dim('─'.repeat(cols) + '\n')); process.stdout.write(chalk.dim(' ↑/↓ navigate q quit'));
process.stdout.write(ansiEscapes.eraseDown);}
function handleKey(raw: string) { const key = parseKey(raw); if (key.name === 'up') state.selectedIndex = Math.max(0, state.selectedIndex - 1); if (key.name === 'down') state.selectedIndex = Math.min(state.items.length - 1, state.selectedIndex + 1); if (key.name === 'q') cleanup(); render();}
function cleanup() { process.stdin.setRawMode(false); process.stdout.write(ansiEscapes.cursorShow); process.stdout.write(ansiEscapes.exitAlternativeScreen); process.exit(0);}
process.stdout.write(ansiEscapes.enterAlternativeScreen);process.stdout.write(ansiEscapes.cursorHide);process.stdout.write(ansiEscapes.clearScreen);process.on('SIGINT', cleanup);process.on('SIGTERM', cleanup);
process.stdin.setRawMode(true);process.stdin.resume();process.stdin.setEncoding('utf8');process.stdin.on('data', handleKey);
// Re-render on resizeprocess.stdout.on('resize', render);
render();Try running this and resizing the terminal window — the layout adapts instantly.
Minimum Size Guard
Section titled “Minimum Size Guard”For complex layouts, it is good practice to show an error when the terminal is too small:
function render() { const cols = process.stdout.columns; const rows = process.stdout.rows;
// Show "too small" message instead of a broken layout if (cols < 40 || rows < 10) { process.stdout.write(ansiEscapes.cursorTo(0, 0)); process.stdout.write(ansiEscapes.clearScreen); process.stdout.write(chalk.red(`Terminal too small: ${cols}×${rows}\n`)); process.stdout.write(chalk.dim('Minimum: 40×10\n')); return; }
// ...normal render}With the ScreenBuffer
Section titled “With the ScreenBuffer”If you are using the ScreenBuffer from the previous lesson, call resize() in the resize handler before re-rendering:
process.stdout.on('resize', () => { buf.resize(process.stdout.columns, process.stdout.rows); render();});The buffer reinitializes its grids, which forces a full repaint on the next flush — necessary since the terminal was cleared by the resize operation.
Module 2 complete. You now have all the building blocks:
- Raw mode input with key parsing
- A render loop pattern (state → render on input)
- Double buffering for flicker-free animation
- Resize handling
Module 3 uses these to build reusable UI components.