Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

altbuf

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

Install

npm install altbuf

Node 18 or newer. Both stdin and stdout must be a TTY.

Pick one item

import {select} from 'altbuf';

const answer = await select({items: ['keep', 'discard', 'cancel']});
// 'keep' | 'discard' | 'cancel' | undefined  — undefined if the user pressed Esc

For 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]),
});

Keep the menu alive

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 with undefined once 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();
    }),
);

Options

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

The handle

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

Keys

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 terminal underneath

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.

Notes

  • If the process dies while a menu is open, an exit handler puts the screen and cursor back. A SIGKILL cannot be caught, so that one still leaves the terminal in the alternate buffer; reset fixes it.
  • Colors use 24-bit 38;2;r;g;b sequences, 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.

License

MIT

About

A fullscreen, arrow-key menu for the terminal's alternate screen buffer.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages