A fullscreen, arrow-key menu for the terminal's alternate screen buffer.
altbuf takes over the screen the way less or vim does — it switches to the alternate
buffer, draws a centred list, and lets you move through it with the arrow keys. When you
leave, the original screen comes back exactly as it was. Your scrollback is untouched.
It has no dependencies, ships as ESM, and is about 400 lines of TypeScript.
████████████░░░░░░░░ 4.7G nebula-drift.mkv
████████████████████ 2.1G harbour-lights.mkv
███████░░░░░░░░░░░░░ 8.3G copper-canyon.mkv
░░░░░░░░░░░░░░░░░░░░ 1.4G twelve-crows.mkv
npm install altbufNode 18 or newer. Both stdin and stdout must be a TTY.
import {select} from 'altbuf';
const answer = await select({items: ['keep', 'discard', 'cancel']});
// 'keep' | 'discard' | 'cancel' | undefined — undefined if the user pressed EscFor anything other than strings, tell it how to draw a row. TypeScript will insist:
const movie = await select({
items: movies,
render: movie => `${humanSize(movie.bytes)} ${movie.name}`,
color: movie => (movie.watched ? [110, 110, 130] : [230, 230, 230]),
});select returns a handle that is also the promise of the eventual result. Await it
straight away for the simple case, or hold on to it and drive the menu while it is open:
const menu = select({
render,
color,
async onSelect(movie, menu) {
await menu.whileSuspended(() => execSync(`mpv "${movie.path}"`, {stdio: 'inherit'}));
},
});
watcher.on('found', movie => menu.add([movie]));
watcher.on('progress', movie => menu.update(movie));
watcher.on('gone', movie => menu.remove(movie));
await menu;Enter behaves differently depending on whether you handle it, and nothing else changes:
- without
onSelect— Enter closes the menu and the promise resolves with the item. - with
onSelect— Enter calls it, the menu stays up, and the promise resolves withundefinedonce the menu is closed.
whileSuspended is how you run something that needs the terminal itself — a video player,
an editor, a browser. It leaves the alternate buffer, restores the cursor and cooked mode,
runs your callback, and comes back afterward, including when the callback throws.
While suspended, stdin is left paused, so a child process spawned with stdio: 'inherit'
gets the keyboard to itself. If instead you read input in-process, attaching a data
listener is not enough — Node will not restart a stream that was explicitly paused, so call
process.stdin.resume() yourself:
await menu.whileSuspended(
() =>
new Promise(resolve => {
process.stdin.once('data', () => {
process.stdin.pause();
resolve();
});
process.stdin.resume();
}),
);| option | meaning |
|---|---|
items |
initial items; may be empty and filled in later with add |
render |
item to a single line of text; optional only when the items are strings |
color |
item to an [r, g, b] foreground colour; omit for the terminal default |
onSelect |
called on Enter; when present, Enter no longer closes the menu |
onKey |
called for any key the menu does not use itself |
onExit |
called on Esc and Ctrl-C instead of closing; you decide what happens |
onError |
called instead of rejecting the promise when a callback throws |
index |
initially highlighted row, default 0 |
loop |
wrap around at the ends, default true |
| member | meaning |
|---|---|
await menu |
the selected item, or undefined |
menu.items |
current items; assign an array to replace them all |
menu.index |
index of the highlighted row |
menu.add(items) |
append rows |
menu.update(item) |
redraw one row after its data changed |
menu.remove(item) |
drop a row |
menu.close(result?) |
close the menu and resolve with result |
menu.suspend() |
give the terminal back without closing the menu |
menu.resume() |
take it again and redraw |
menu.whileSuspended(fn) |
suspend, run fn, resume — even if fn throws |
menu.suspended |
the menu is open but not currently drawing |
menu.closed |
the menu is finished and the promise has settled |
| key | action |
|---|---|
| ↑ / ↓ | move one row, wrapping around unless loop: false |
| Page Up / Page Down | move one screen |
| Home / End | first / last row |
| Enter | select |
| Esc, Ctrl-C | exit |
Anything else reaches onKey, one key per call — a burst of keypresses that arrives as a
single read is split apart and delivered in order.
The pieces the menu is built from are exported too, so you can drive the alternate buffer yourself:
import {Terminal, withAlternateBuffer, displayWidth, truncateToWidth} from 'altbuf';
await withAlternateBuffer(async () => {
process.stdout.write(Terminal.cursorPosition(3, 10));
process.stdout.write(Terminal.rgbForeground(200, 120, 60));
process.stdout.write('drawn on a clean screen');
});withAlternateBuffer nests, hides the cursor, and restores the screen even if the callback
throws. displayWidth and truncateToWidth measure and cut text while ignoring ANSI escape
sequences, counting combining marks as zero columns and CJK and emoji as two — which is why
render may return colored, pre-formatted strings without upsetting the layout.
- If the process dies while a menu is open, an
exithandler puts the screen and cursor back. ASIGKILLcannot be caught, so that one still leaves the terminal in the alternate buffer;resetfixes it. - Colors use 24-bit
38;2;r;g;bsequences, which every current terminal understands. - One menu at a time: two open menus would fight over the screen and the keyboard.
- The list is centred on the screen and scrolls, keeping the highlighted row near the middle, once it is taller than the window.
MIT