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.
The SelectList Component
Section titled “The SelectList Component”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))); } }}Integrating a Menu into an App
Section titled “Integrating a Menu into an App”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();Scrollable Lists
Section titled “Scrollable Lists”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.
Multi-Select Lists
Section titled “Multi-Select Lists”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('[ ] ');