Skip to content

3.2 Menus & Selection Lists

A menu is a list of items with a movable selection cursor. It is the most common interactive element in TUIs — file browsers, command palettes, settings screens all use it.

examples/module-3/select-list.ts
import chalk from 'chalk';
import ansiEscapes from 'ansi-escapes';
export type ListItem = {
label: string;
value: string;
disabled?: boolean;
};
export type SelectListOptions = {
col: number;
row: number;
width: number;
items: ListItem[];
selectedIndex: number;
maxVisible?: number; // how many items to show before scrolling
selectedColor?: (s: string) => string;
normalColor?: (s: string) => string;
disabledColor?: (s: string) => string;
prefix?: string; // prefix for selected item (default '▶ ')
};
export function renderSelectList(opts: SelectListOptions) {
const {
col, row, width, items, selectedIndex,
maxVisible = items.length,
selectedColor = chalk.bgBlue.bold.white,
normalColor = chalk.white,
disabledColor = chalk.dim,
prefix = '',
} = opts;
// Compute scroll offset so selected item is always visible
const scrollOffset = Math.max(0, selectedIndex - maxVisible + 1);
const visible = items.slice(scrollOffset, scrollOffset + maxVisible);
visible.forEach((item, i) => {
const absoluteIndex = scrollOffset + i;
const isSelected = absoluteIndex === selectedIndex;
const itemPrefix = isSelected ? prefix : ' '.repeat(prefix.length);
const rawText = `${itemPrefix}${item.label}`;
const padded = rawText.padEnd(width).slice(0, width);
let colorFn = item.disabled ? disabledColor : isSelected ? selectedColor : normalColor;
process.stdout.write(ansiEscapes.cursorTo(col - 1, row - 1 + i));
process.stdout.write(colorFn(padded));
});
// Show scroll indicator if list is longer than maxVisible
if (items.length > maxVisible) {
const below = items.length - scrollOffset - maxVisible;
process.stdout.write(ansiEscapes.cursorTo(col - 1, row - 1 + maxVisible));
if (scrollOffset > 0 && below > 0) {
process.stdout.write(chalk.dim(`${scrollOffset} more ↓ ${below} more`.padEnd(width)));
} else if (scrollOffset > 0) {
process.stdout.write(chalk.dim(`${scrollOffset} more above`.padEnd(width)));
} else {
process.stdout.write(chalk.dim(`${below} more below`.padEnd(width)));
}
}
}
examples/module-3/02-menu.ts
import chalk from 'chalk';
import ansiEscapes from 'ansi-escapes';
import { renderSelectList, type ListItem } from './select-list';
import { drawBox } from './draw-box';
import { parseKey } from '../module-2/01-keypress-parser';
const ITEMS: ListItem[] = [
{ label: 'New File', value: 'new' },
{ label: 'Open File...', value: 'open' },
{ label: 'Save', value: 'save' },
{ label: 'Save As...', value: 'save-as' },
{ label: '─────────────', value: '', disabled: true },
{ label: 'Settings', value: 'settings' },
{ label: 'About', value: 'about' },
{ label: '─────────────', value: '', disabled: true },
{ label: 'Quit', value: 'quit' },
];
type State = {
selectedIndex: number;
lastAction: string;
};
const state: State = { selectedIndex: 0, lastAction: '' };
function render() {
const cols = process.stdout.columns;
process.stdout.write(ansiEscapes.cursorTo(0, 0));
drawBox({
col: 2, row: 2,
width: 24, height: ITEMS.length + 2,
title: 'Menu',
borderStyle: 'rounded',
borderColor: chalk.cyan,
});
renderSelectList({
col: 3, row: 3,
width: 22,
items: ITEMS,
selectedIndex: state.selectedIndex,
maxVisible: ITEMS.length,
selectedColor: chalk.bgCyan.black,
});
// Status bar
const statusRow = ITEMS.length + 5;
process.stdout.write(ansiEscapes.cursorTo(1, statusRow));
process.stdout.write(chalk.dim('↑/↓ navigate Enter select q quit'));
if (state.lastAction) {
process.stdout.write(ansiEscapes.cursorTo(1, statusRow + 1));
process.stdout.write(chalk.green(`→ Selected: ${state.lastAction}`));
}
process.stdout.write(ansiEscapes.eraseDown);
}
function nextNonDisabled(from: number, direction: 1 | -1): number {
let idx = from + direction;
while (idx >= 0 && idx < ITEMS.length) {
if (!ITEMS[idx].disabled) return idx;
idx += direction;
}
return from; // no valid target, stay
}
function handleKey(raw: string) {
const key = parseKey(raw);
switch (key.name) {
case 'up':
state.selectedIndex = nextNonDisabled(state.selectedIndex, -1);
break;
case 'down':
state.selectedIndex = nextNonDisabled(state.selectedIndex, 1);
break;
case 'enter': {
const item = ITEMS[state.selectedIndex];
if (!item.disabled) {
state.lastAction = item.label;
if (item.value === 'quit') cleanup();
}
break;
}
case 'q':
cleanup();
return;
}
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);
render();

When maxVisible < items.length, the list scrolls to keep the selected item visible. The key is the scrollOffset variable in renderSelectList: it always positions the window so the selected item is the last visible one when scrolling down, or the first when scrolling up.

Try adding 20 items and setting maxVisible: 6 — scroll indicators appear automatically.

For a multi-select list (checkboxes), add a selected: Set<string> to your state:

type State = {
focusIndex: number;
selected: Set<string>;
};
// Toggle on Space:
case 'space': {
const item = items[state.focusIndex];
if (state.selected.has(item.value)) {
state.selected.delete(item.value);
} else {
state.selected.add(item.value);
}
break;
}
// Render with checkbox prefix:
const checked = state.selected.has(item.value);
const prefix = checked ? chalk.green('[✓] ') : chalk.dim('[ ] ');