flagstaff

flagstaff

Spinners, progress, boxes and tables on one terminal frame loop, with a static projection for pipes, screen readers and agents, and a plugin host. No dependency outside the burgee family. Drop-in paths for ora, log-update, boxen and cli-table3.

flagstaff is a terminal frame loop: it hoists a component, holds it, changes it and lowers it. Every mode but the terminal gets a static projection — one line per state on a pipe, one event per transition under --json, plain text for a screen reader — so no state is lost to the log an agent reads back. Plugins are data, and there is no layout engine.

Drop-in for ora, log-update, boxen and cli-table3: npx burgee migrate moves the imports, each path graded by the incumbent's own suite — see Migrating.

ora animates a spinner on a terminal and, off one, writes the line it started with and the line it stopped with — every state in between is lost. Ink fixes the terminal by shipping React and a layout engine. flagstaff takes four dependencies, all from this repository: roundel, paratext, linegauge and closeout.

A flagstaff is the simplest part of the whole apparatus and the only one that is always in view: a flag is hoisted on it, held there, changed, and lowered when it is done. That is a terminal render loop — the place frames are hoisted, held, changed and lowered, in view of whoever is reading.

Install

npm install flagstaff
pnpm add flagstaff
yarn add flagstaff
bun add flagstaff

Quick start

import { hoist } from 'flagstaff/loop';
import { spinner } from 'flagstaff/spinner';

const rt = {
  env: process.env,
  isTTY: { stdout: Boolean(process.stdout.isTTY) },
  stdout: process.stdout,
  stderr: process.stderr,
  clock: {
    now: () => performance.now(),
    schedule(fn, ms) {
      const timer = setTimeout(fn, ms);
      return () => clearTimeout(timer);
    },
  },
};

const flag = hoist(spinner(), rt, { text: 'building' });
// ... work ...
flag.update({ text: 'linking' });
flag.lower({ text: 'built', status: 'ok' });

rt is a runtime — { env, isTTY: { stdout }, stdout, stderr, clock }, written out above; burgee's satisfies it, so does a literal with manualClock() in a test. Which of the five modes runs is decided once by roundel's output policy, never by this package:

modewhat the same three calls write
tty⠋ building repainted in place on the clock, then ✔ built left on screen
pipe, ci… building ⏎ … linking ⏎ ✔ built — one line per state change, no \r, no escape
json{"event":"spinner","state":{"text":"building"}} … one NDJSON event per transition, on stderr
accessiblethe static text, never a redraw

What is here

The loop

hoist(component, rt, initial, { json }) returns { update(state), lower(state?), mode }. A component is { name, static(state), frame?(t, state), interval? }. static is required and is the artifact: what a pipe, an agent, a screen reader and the docs gallery read. frame is optional and decorative. Nothing in the loop reads process; time comes from rt.clock, so manualClock() makes a spinner's terminal output a fixed string a test can assert byte for byte.

The plugin host

A plugin is one plain object, validated against schema.json — the same file that ships in the tarball — and registered once:

// a third-party plugin, in full
export default {
  name: 'nyan',
  spinners: { nyan: { frames: ['≋', '≈', '~'], interval: 80, static: '…' } },
};
import { register } from 'flagstaff/plugin';
register(nyan);
spinner('nyan');

Keys: spinners, borders, glyphs (ok, fail, warn, info, running — change them and every built-in that draws one changes), tokens (a roundel theme), components. A spinner or component without a static is refused at register() with E_NO_STATIC_PROJECTION and a fix. The built-in dots and line styles are a plugin of exactly this shape, registered through the same door, so the built-ins cannot grow an API a plugin cannot reach.

register() is the only way in, and that is a property rather than a convention: registered() hands back a copy — new maps over the frozen objects register() stored — so registered().spinners.set(…) puts nothing in the registry and .clear() empties nothing. A contribution that never met validate() cannot be reached by spinner(), box() or the gallery, which is what makes "refused at the door" (U3) a fact about the code rather than advice. Freezing also means the object you registered stays yours: edit it afterwards and the registry does not change.

A component may declare sample: { running, done } — the two states flagstaff check and the docs gallery show it with. The loop never reads it; a running program's state comes from the program. Without one, both assume { phase: 'running' } / { phase: 'done' } and say so in the output, rather than rendering an invented state as if it were yours.

The built-ins

Five components, each on its own subpath, each answering the static projection for itself:

import { progress } from 'flagstaff/progress';
import { tasks } from 'flagstaff/tasks';
import { box, boxComponent } from 'flagstaff/box';
import { table, tableComponent } from 'flagstaff/table';
componenton a terminaleverywhere else
spinner⠹ building… building, then ✔ built
progressa bar of blocks12/30 files · 40%
tasksevery task, the running one animatedone line per task that has settled
boxthe border, padding and titletitle: text
tablethe gridone line per row of header: value pairs

That table is the package's argument in one place. A bar of █ in a log file tells an agent nothing and tells a screen reader less; the count and the percentage tell both.

Inside a burgee --mcp tool call that asked for progress, flagstaff/progress goes one step further and draws nothing: it sends its count to the client as notifications/progress, with the static line as the message. That bridge lives on the subpath only; progress from the root flagstaff entry always draws.

box and table are also plain string functions, because most callers want the string:

box('Ready on :3000', { title: 'dev', width: 40 });
table([['ora', '99'], ['log-update', '99']], { head: ['host', 'tests'] });

No layout engine, and there will not be one: these measure with width(), wrap with wrap(), and join strings.

A table column can be bounded. columns is positional, like align: max caps a column whatever its content, and min keeps it open while another column can give way. Both count the content's display width, not the padding. When the mins add up past width, the mins win and the table is wider than asked. A cell wider than its column wraps; overflow: 'truncate' cuts it to one row ending in … instead, by display width, with a link kept around what is left. The static projection has no columns to cap, so it prints every cell whole.

table(rows, { head: ['id', 'status'], columns: [{ max: 12, overflow: 'truncate' }, { min: 10 }] });

A log tail and a tab bar

Two output-only components for screens built on flagstaff — controlroom's panes, or a plain scrolling terminal. Neither reads a key.

import { logTail } from 'flagstaff/log-tail';
import { tabBar } from 'flagstaff/tab-bar';

hoist(logTail({ height: 5 }), rt, { lines: [{ step: 'Installing' }, 'npm install', 'added 12 packages'] });
hoist(tabBar(), rt, { tabs: ['Status', 'Tail logs', 'Visualizer'], active: 1 });
componenton a terminaleverywhere else
logTailthe last n rows, ┊ before each line, ◆ on the step in progress — pinned to the top once it scrolls outthe whole stream, appended: each new line printed once, nothing repainted
tabBarStatus · Tail logs · Visualizer, the active tab styled through roundel's heading (bracketed without colour)the active tab's label

Importing either registers its built-in (log-tail, tab-bar) through the same public register() a plugin uses, so a plugin can replace it; the ┊ and ◆ marks are the tail and step glyphs. The task list's pending mark is the pending glyph too — a space unless a plugin sets one, ◻ say.

A streamed reply and a diff

Two components for chat-style programs, where a model's reply arrives a token at a time and an edit is shown before it is written.

import { markdown } from 'flagstaff/markdown';
import { diff } from 'flagstaff/diff';

let text = '';
const reply = hoist(markdown(), rt, { text });
for await (const token of stream) reply.update({ text: (text += token) });
reply.lower({ text, done: true });

hoist(diff(), rt, { diff: patch }).lower();
componenton a terminaleverywhere else
markdownthe reply so far: headings, lists, emphasis, inline code and fenced code styled through roundel; with no colour, the source as writtenthe markdown's own source, committed a block at a time — each block printed once, when a later line closes it
diffold and new line numbers on every line, removals and additions colouredthe diff unchanged, byte for byte

A block is committed only by a complete line — a blank line, a heading, a fence, a closing fence — so wherever the stream is cut, the blocks committed so far are the whole reply's first blocks; the suite cuts a fixture at every byte offset and holds that. There is no syntax highlighting inside a fence. markdownBlocks(text, done) is exported for a program that places committed blocks itself, as controlroom's inline screen does.

Writing a whole frame

hoist repaints one component. A program that lays several out in one frame paints the frame through the same writer hoist uses:

import { frameWriter } from 'flagstaff/loop';

const frame = frameWriter(process.stdout);
frame.paint(['Status · Tail logs', '┊ npm install', '┊ added 12 packages']);
frame.paint(['Status · Tail logs', '┊ npm install', '┊ added 13 packages']); // rewrites one row
frame.release(); // the frame stays on screen as scrollback

paint writes only the rows that changed since the last paint, inside one synchronized-output block (ESC[?2026h … ESC[?2026l), so a terminal that supports it never shows a half-drawn frame and one that does not ignores the two sequences. It is the only grid-writing code in the family: controlroom composites through it rather than painting on its own.

Bringing a corpus with you

The ecosystem already has ~80 spinner styles and eight border sets, as plain JSON. Neither is bundled here — the weight of a corpus nobody asked for is the thing this package exists to avoid — so flagstaff/import turns the one you have into an ordinary plugin:

import cliSpinners from 'cli-spinners';
import { fromCliSpinners } from 'flagstaff/import';
import { register } from 'flagstaff/plugin';

register(fromCliSpinners(cliSpinners));
spinner('moon');

fromCliBoxes(cliBoxes) does the same for borders, after which box('…', { border: 'arrow' }) draws with one. Both go through the same register() and the same schema, so the gallery opens full and a third-party plugin starts as a copy of one of these. The importer is 838 B and reaches nothing.

flagstaff check

npx flagstaff check ./nyan.mjs

Loads the file, validates it, and prints every contribution in all five modes side by side — escapes made visible — so an author, or an agent that just wrote one, sees the static projection next to the animation before anything ships. Exit 1 on a refusal, with the code and the fix.

It opens with a census of what it found and closes with the verdict, so ok is never printed before the rendering that would justify it:

nyan — 1 spinner, 0 borders, 0 components, 0 glyphs, 0 tokens
spinner nyan
  tty         ␛[?25l␛[?2026h≋ working␛[?2026l␛[?2026h␛[1G␛[0J…
  pipe        ~nyan~ working⏎ ✔ done⏎
  …
nyan: ok

0 spinners, 0 components is how a misspelled key tells on itself. The schema is additionalProperties: true on purpose — a key another package in the family reads belongs in the same object — so a typo cannot be refused by the schema, and check is the surface that has to notice:

typo — 0 spinners, 0 borders, 0 components, 0 glyphs, 0 tokens
  unknown     componets, spinner — flagstaff reads none of these; a key another package in the family reads is allowed here
E_NO_CONTRIBUTION: typo registers, but contributes nothing flagstaff can render
  fix: flagstaff reads spinners, borders, components, glyphs and tokens; check those spellings. …

Each component block names the state it was rendered with — the component's own sample when it declares one, and otherwise the assumed { phase } shape, said out loud. A static that throws on the state it is handed is a refusal like any other, naming the modes it broke in (json emits the state and never calls static, so it is usually the one that survives):

E_COMPONENT_THREW: g threw in tty, pipe, ci, accessible
  fix: `static(state)` must return a string for the state it is rendered with; …

Migrating

Four drop-in paths, one import each. npx burgee migrate --dry-run lists the imports the codemod would rewrite — only drop-ins graded level with their incumbent — and npx burgee migrate makes the change (Migrate).

The ora path

flagstaff/ora is ora 10's whole API, graded 223 / 223 by ora's own test suite through compat-oracle. One import changes:

-import ora from 'ora';
+import ora from 'flagstaff/ora';

Everything else stays: ora({ text, spinner, color, indent, prefixText, suffixText }), .start() .stop() .succeed() .fail() .warn() .info() .stopAndPersist(), oraPromise(), the spinners corpus, the stream hooks that keep a console.log above the frame, the synchronized-output sequences, the pause while another writer's line is unfinished, the frame kept above the last row of the screen, the stdin discarder.

What changes is the bill. ora 10.0.0 ships 128,182 B of JavaScript across seventeen packages — ora, chalk, cli-spinners, string-width, log-symbols, cli-cursor, restore-cursor, onetime, mimic-function, signal-exit, is-interactive, is-unicode-supported, stdin-discarder, yoctocolors, strip-ansi, ansi-regex, get-east-asian-width. flagstaff/ora is 53,486 B across two — itself and roundel — 42% of ora's, and 20,250 B of that is the spinner corpus ora's API re-exports. Nothing in it reaches the frame loop, so a program that migrates its spinner today can adopt hoist() a file at a time, or never (measured).

Both sides are counted the same way, so the number reproduces: shipped code and data — .js/.mjs/.cjs plus the .json a module imports, package.json never counted. Ours is the import graph walked from dist/ by weight.test.ts; ora's is every package that graph touches in ora's own resolved tree, counted whole (measured).

The cursor comes back the way ora's does. A spinner that hid the cursor restores it on a clean exit and on SIGINT, SIGTERM and SIGHUP — node does not run 'exit' listeners for a signalled process, and Ctrl+C is how a spinner usually dies — then re-raises the signal so the process still terminates, unless the program installed its own handler for it. That is what ora buys with restore-cursor → signal-exit; here it is twenty lines and no dependency. ora's own 99 never kill a process, so ora.test.ts grades it instead.

The repo's accessible switch does not reach this surface. CLI_ACCESSIBLE=1 changes what roundel/policy decides for every other entry point in the stack, but flagstaff/ora imports roundel/chalk and never the policy, because a façade that reinterpreted its host would fail the host's suite — so on a terminal CLI_ACCESSIBLE=1 still animates and still writes cursor escapes (measured: 4 escapes, cursor hidden), where CI=true disables the spinner outright (0 escapes) because that is ora's own rule. Off a terminal it is moot. Use hoist() when you want the switch to be honoured.

The static projection is the reason to move on eventually, not the reason to move: hoist() is what gives a pipe one line per state instead of frames. flagstaff/ora is the door, and it is deliberately ora's behaviour to the byte.

The log-update path

flagstaff/log-update is log-update 8's API, graded 99 / 99 by log-update's own test suite — which renders every frame through a real terminal emulator and asserts the screen, not the bytes.

-import logUpdate from 'log-update';
+import logUpdate from 'flagstaff/log-update';

logUpdate(), .clear(), .done(), .persist(), createLogUpdate(stream, options) and logUpdateStderr, with the row-level diffing intact: a five-row frame whose last row is a counter costs one row of output per tick, not five.

log-update ships 114.3 KB across sixteen packages. This subpath reaches two, both from this repository: linegauge/wrap for the wrapper, and closeout for the cursor. It carries no port of slice-ansi — the wrapper already makes every row self-contained, so clipping a frame to the terminal's height is an array slice. signal-exit, 22.0 KB of those sixteen, is closeout's to own, shared with the ora façade because both incumbents port the same cli-cursor → restore-cursor → signal-exit chain. Ctrl+C mid-frame puts your cursor back, and still terminates — unless your program installed its own SIGINT handler, in which case it is delivered once, to you, and this stays out of it (measured).

This is the one façade that lowers a layer guarantee, and it says so. R5 — no cursor escape off a terminal — cannot survive here: log-update's own suite asserts erase sequences on a plain non-TTY stream, so a façade that suppressed them would fail the suite that is the whole claim. What survives is the half the complaint behind R5 was actually about: nothing this writes is ever a \r, and never an absolute cursor-home — every move is a relative row move — on a terminal or off one, so a captured transcript stays parseable. log-update.test.ts asserts both halves, and hoist() is what gives you the whole guarantee.

The boxen path

flagstaff/boxen is boxen 9's API, graded 213 / 213 by boxen 9.0.0's own test suite — whose cases assert the exact characters the box comes out as. boxen 8 is no longer graded or claimed: 9 changed two of its answers (a hex colour must be real hex, and vertical / horizontal are a fallback for the sides rather than an override), and this follows 9.

-import boxen from 'boxen';
+import boxen from 'flagstaff/boxen';

borderStyle (all eight of cli-boxes', a style object, or none), borderColor, backgroundColor, borderBackgroundColor, dimBorder, title, titleColor and titleAlignment, footer and footerAlignment, textAlignment, padding, margin, width, maxWidth, height, float, fullscreen, and _borderStyles. A tab, a backspace or a cursor move inside the text, a label or a border is written the way a terminal would draw it, as boxen 9 does, so it cannot break the box.

The drawing is the contract here, and matching it byte for byte is the compatibility claim rather than a way of avoiding one: a user leaving boxen cares about one thing, whether the box still looks the same. It carries cli-boxes' table itself rather than reading the plugin registry — a façade whose drawing changed when somebody registered a plugin would be reinterpreting its host. Named borders through the registry are flagstaff/box's job.

The cli-table3 path

flagstaff/cli-table3 is cli-table3 0.6.5's API, graded 29 / 29 by cli-table3's own test suite — the 38 of its cases that go through the public surface, less the nine that grade cli-table, the legacy incumbent, and so pass whatever the target is. The other 197 require('../src/…') and test its four internal modules directly; those are reported beside the number and never gate it, because passing them would mean copying cli-table3's file layout rather than matching its behaviour.

-const Table = require('cli-table3');
+import Table from 'flagstaff/cli-table3';

head, chars, style (padding, head, border, compact), colWidths, rowHeights, colAligns, rowAligns, truncate, wordWrap, wrapOnWordBoundary, per-cell colSpan, rowSpan, hAlign, vAlign, href, and the debug channel with table.messages and Table.reset(). It extends Array, because cli-table3 does and its callers push rows onto it.

Four dependencies folded into one module rather than four: cli-table3's table.js, layout-manager.js, cell.js and utils.js become one file, because the architecture is not the contract — the drawing is.

Compatibility

Each façade is graded by its incumbent's own suite, unedited, through compat-oracle, beside a control run of the same suite against the incumbent itself. What each suite does and does not reach is under its path above — cli-table3's number counts only the cases that go through its public surface, and ora's never kills a process, so ora.test.ts grades the signal path instead. The current grades are generated under Benchmarks below and published on the compatibility page.

Weight

Every subpath is a lock, not a convention, and the numbers below are asserted by weight.test.ts against dist/, not estimated: flagstaff/loop reaches 4.4 KB on disk and never the plugin registry; flagstaff/plugin 8.4 KB, of which 2.4 KB is the schema; flagstaff/spinner 9.4 KB; flagstaff/ora 44.1 KB — 53.5 KB with roundel counted, against ora 10's own 128.2 KB; flagstaff/log-update 29.6 KB, against log-update's own 114.3 KB across sixteen, reaching only linegauge/wrap and closeout; flagstaff/boxen 17.6 KB — 67.8 KB with linegauge and roundel counted, against boxen 9's own 115.8 KB across fourteen; flagstaff/cli-table3 32.9 KB — 42.3 KB with roundel, against cli-table3's own 106.0 KB across seven. The three façades share wrap.js and width.js, and the first two share closeout; none reaches another's port, and none reaches the core. sideEffects: false lets a bundler drop what a program does not use. ESM with a default condition, so require('flagstaff/spinner') works from CommonJS on Node 22.13+ (measured).

Benchmarks

Every number here is produced by npm run bench and published at burgee.interlace.tools/docs/benchmarks.

Graded by the incumbent's own test suite:

suitepassing
boxen213 / 213
cli-table329 / 29
log-update99 / 99
ora223 / 223

For agents

  • Off a terminal, frames become facts. Every component has a required static projection: one line per state on a pipe, one NDJSON event per transition under --json, plain text for a screen reader — never \r after \r into the log an agent reads back.
  • The mode is decided once, by roundel's output policy, never by this package, so the spinner, the prompt and the help cannot disagree about the terminal.
  • A plugin can be checked before it ships. npx flagstaff check ./nyan.mjs prints every contribution in all five modes side by side, escapes made visible, and exits 1 with the code and the fix on a refusal — so an agent that just wrote one sees the static projection before anything ships.
  • The docs are machine-readable at flagstaff.interlace.tools/llms.txt and llms-full.txt.

Every component, every registered plugin and every border is on the gallery, which is generated by running them — the static projection beside the animation, in all five modes.

Design documents

The intent and the design are committed beside the code, with the status of every requirement: .sdlc/intents/flagstaff/. If your CLI needs a component the gallery does not show, open an issue.

API

The loop, the plugin host and the built-ins are under What is here; every export, with its types, is on flagstaff.interlace.tools.

Where it sits

Plugins register under the tokens, glyphs, spinners, borders, components keys, against the one schema the whole family shares.

controlroom builds on it, and it builds on closeout, linegauge, paratext, roundel.

The family

Eleven packages, one repository, one release pipeline. Each installs and works on its own, and each owns one job. Six of them — bellpull, closeout, linegauge, paratext, roundel, seniority — depend on nothing; the others depend only on packages in this table, and every dependency points one way, down the layers.

No package declares a dependency from outside the family; react and react-reconciler are optional peers of controlroom, which npm does not install; vitest is an optional peer of oxmutate, which npm does not install. package-shape-lock holds that for every manifest, and independence-install-lock installs each package alone and finds nothing but the family packages it declares.

Releases are published by one workflow through npm trusted publishing: no npm token is used, and each release carries SLSA provenance, which npm view <package> dist.attestations shows. trusted-publishing-lock keeps both true.

PackageWhat it isMigrates from
burgeeThe CLI framework: one declaration, every surfacecommander, yargs and meow
roundelColour: one output policy, semantic tokens, a themechalk, colorette, kleur, picocolors, supports-color and yoctocolors
flagstaff (this package)The frame loop: spinners, progress, boxes and tablesora, log-update, boxen and cli-table3
caiquePrompts that are flags first, and never hang@inquirer/core
linegaugeMeasuring, wrapping, truncating and slicing styled textstring-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line
paratextHyperlinks, images, title, clipboard and notificationsansi-escapes, terminal-link and term-img
seniorityConfiguration precedence and discovery, with provenancecosmiconfig, lilconfig, dotenv and rc
closeoutExit handlers, terminal restore and a bounded shutdownsignal-exit, exit-hook, restore-cursor, cli-cursor and onetime
bellpullSubprocesses, and which executable actually rancross-spawn and which
controlroomKeyboard-driven terminal screens, inline or full-screenink
oxmutateMutation testing on the oxc engine, inside your own VitestStryker

Every migration guide, and the family-wide compatibility and benchmarks pages, are on burgee.interlace.tools.

Contributing

Issues and pull requests are welcome at ofri-peretz/burgee; read CONTRIBUTING.md first. Report a vulnerability privately, as SECURITY.md describes — never in a public issue.

Licence

MIT © Ofri Peretz — see LICENSE.

On this page