controlroom
Keyboard-driven terminal screens, inline or full-screen, with a static projection for pipes, CI and --json. No dependency outside the burgee family; react and react-reconciler are optional peers, loaded only by controlroom/ink. Drop-in path for ink.
controlroom draws keyboard-driven terminal screens — panes, tabs with key hints, a
checklist, a log tail, sections that collapse — either inline, under a transcript that
flows into the terminal's own scrollback, or in the alternate screen, laid out again on
resize. The same program gives every other caller a static projection: stable lines in a
pipe, in CI and for a screen reader, and NDJSON events under --json for an agent. It never
waits for a key that nobody can press.
Drop-in for ink: npx burgee migrate moves an ink program to controlroom/ink, graded by
ink 8's own test suite, 1304 of 1304 cases; @inkjs/ui's suite passes 102 of 102 against it.
The inline shape is Ink's, and Claude Code's.
A control room is where a system is watched and run from.
Install
npm install controlroom
pnpm add controlroom
yarn add controlroom
bun add controlroomQuick start
A pane is a flagstaff component, so the quick start installs both:
npm install controlroom flagstaffimport { open, processRuntime } from 'controlroom';
import { tasks } from 'flagstaff/tasks';
const steps = [{ title: 'Install', status: 'running' }, { title: 'Configure', status: 'pending' }];
const screen = open(processRuntime(), {
layout: 'work',
panes: { work: { component: tasks(), state: { tasks: steps }, label: 'Tasks' } },
});
steps[0].status = 'ok';
screen.update('work', { tasks: steps });
screen.close();On a terminal this is a live pane redrawn in place; piped, it prints each task's line once it settles;
under --json it writes one NDJSON event per change to stderr. A pane is any flagstaff
component, and the layout is rows and columns of them.
What it does
- Two live shapes, one program.
open()keeps the main screen by default (screen: 'inline'): a live region repaints under a committed transcript that flows into the terminal's own scrollback.screen: 'alternate'takes the whole terminal. Both lay out again when the terminal is resized. - Layout as arithmetic. Rows and columns of panes, sized in fixed cells, fractions with a minimum, or to fit their content, measured with linegauge. A terminal too small for the minimums clips the later panes, never the earlier ones.
- Keyboard-driven, with keymaps as data. Tabs, focus, collapse and scrolling are a pure reducer over
a keymap that maps a key to an action name, and the hint line is generated from that keymap,
so it cannot name a key that is not bound. A pane taller than its rectangle scrolls (↑↓,
PgUp/PgDn, Home/End on the focused pane), follows new lines while it is at its bottom, and
says
↓ Nwhen it is not. An input line, caique's editor, can sit in any pane. - A static projection for every other caller. Off a terminal, each pane prints its own
static projection under its label: stable lines for a pipe, CI and a screen reader, and
NDJSON events on stderr under
--json. Nothing waits for a key, so nothing hangs. - Plugins as data. Keymaps and panes register through one
register()against the family schema, andcontrolroom check <plugin-file>shows what a plugin contributes, or refuses it with a code and a fix. See Plugins.
Migrating
From ink, the path is one import, plus the reconciler ink used to install for you, graded by ink's own test suite (R11, R13):
- import { render, Box, Text } from 'ink';
+ import { render, Box, Text } from 'controlroom/ink';npm install react react-reconcilerreact and react-reconciler are optional peers: the program's own React renders through
React's own reconciler, and the native API never loads either. Without them,
controlroom/ink refuses on first import with E_PEER_MISSING and the install line as its
fix. Layout is a TypeScript port of yoga's flexbox for the props ink exposes; there is no
yoga.
In a user's bundle the drop-in is no heavier than ink: controlroom/ink with react and
react-reconciler against ink with react, as the weight axis measures it (R14, published
on the benchmarks page).
Packages written for ink run unchanged when 'ink' resolves to controlroom/ink. A bare
npm: alias cannot do that, because it names a package and not a subpath, so 'ink' would be
the native API. Instead, one line in your package.json points 'ink' at a two-file package
of your own that re-exports the drop-in:
{
"dependencies": {
"ink": "file:./ink",
"controlroom": "*",
"react": "^19.3.0",
"react-reconciler": "^0.34.0"
}
}// ink/index.js — and ink/package.json: { "name": "ink", "version": "8.0.0", "type": "module", "exports": "./index.js" }
export * from 'controlroom/ink';The version is the ink API the drop-in implements, so every component's peer range on ink is
met and npm installs no other ink. That is how ink-spinner, ink-text-input and
ink-select-input run, as published, in
examples/ink-ecosystem,
and how examples/chat-cli-ink,
a whole Ink app, runs on the drop-in (R17, R22). @inkjs/ui runs the same way: its own suite
passes 102 / 102 with 'ink' resolved to this package. None of them has a façade here.
From blessed, neo-blessed and terminal-kit there is no drop-in — their surfaces are
too large to reproduce honestly. Each has a coming-from guide, and burgee migrate reports the
common screen, box, list and key patterns with a link to the matching section (R18).
Compatibility
Ink's own suite is vendored into compat-oracle at ink 8.0.0 and @inkjs/ui's at 2.0.0,
each with a control run against the real package and a baseline that only ratchets (R13,
R17), and both grade controlroom/ink. ink's passes 1304 of 1304 cases: 1309 are graded,
four of the five set aside test ink's own repository build and one type-checks its source
tree, which no vendored suite has. @inkjs/ui's passes 102 / 102, graded unmodified with
'ink' resolved to this package, on @inkjs/ui's own React 18; its one spinner case that
expects ink 5's unmount writes is set aside, because real ink 8 fails it too. ink 6.8.0 is
graded as a previous major — 540 / 584, where ink 8 changed what 6 asserted — and not claimed.
The rows are on the
compatibility page.
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:
| suite | passing |
|---|---|
ink | 1304 / 1304 |
inkjs-ui | 102 / 102 |
For agents
The reason the package exists is that a full-screen program is useless to an agent. The same program that draws panes on a terminal writes:
- stable lines — each pane's static projection, under its label — to a pipe, in CI and for a screen reader;
- NDJSON events under
--json, on stderr, one per state change; - and it never waits for a key: off a terminal,
open()reads no keys at all, anddispatch(action)is what a program has instead.
The family's machine-readable docs are at burgee.interlace.tools/llms.txt.
API
open(runtime, options)— a screen.screen: 'inline'(the default) or'alternate';layout;panes(a flagstaff component and its state, by name);keymap(key spec → action, as data);tabs,focus,json, andonAction(action, screen). Returns{ mode, interactive, state, update(pane, state), commit(text), dispatch(action), close() }.commit()writes text above the live region once, into scrollback (ink's<Static>).processRuntime()— the real process, as theRuntimeevery other function takes.layout(tree, area, contents?)— a rectangle per pane. A part's size is a number of cells,{ fr, min }, or'fit'(measured by linegauge). A terminal too small for the minimums clips the later parts and never the first.initial,reduce(state, action, extents?)— tabs, focus, collapse and scrolling (scroll.up,scroll.down,scroll.pageUp,scroll.pageDown,scroll.top,scroll.bottom,resize);hints(keymap, labels)— the hint line, generated from the keymap so it cannot name an unbound key.compose,extents,fit,render,collapse,distribute— the compositor's arithmetic.extents()measures whatreduceneeds to scroll;composewithlive: falseprints every line of every pane, whatever was scrolled.controlroom/ink— ink 8's API:render,renderToString,Box,Text,Static,Transform,Newline,Spacer, every hook (useAnimation,usePaste,useBoxMetricsanduseWindowSizeamong them) andmeasureElement.status— kept for the reserved release's one export.
It builds on its siblings rather than beside them: flagstaff draws the panes and writes every frame, caique reads the keys, closeout restores the terminal, linegauge measures, and roundel decides the output mode.
Plugins
controlroom hosts two keys, keymaps and panes, and both are data. A keymap maps a
key, spelled the way caique/keys spells it (left, enter, ctrl+c, shift+tab, s), to
an action name, and labels the actions the hint line should show. A pane names the flagstaff
component that draws it and the label a pipe prints above it. A tab bar is a pane over
flagstaff's tab-bar.
A plugin file default-exports the plugin:
// vim-keys.mjs
export default {
name: 'vim-keys',
keymaps: {
vim: { keys: { h: 'tab.prev', l: 'tab.next' }, labels: { 'tab.prev': 'switch tab', 'tab.next': 'switch tab' } },
},
panes: { log: { component: 'log-tail', label: 'Log' } },
};Check it before you ship it:
npx controlroom check ./vim-keys.mjsIt prints each keymap as the hint line it generates, and each pane as the component that
draws it, then ok. A refusal carries a code and a fix.
Register it before the first screen opens. A pane draws with a component flagstaff has
registered, and open() throws a ScreenError for one nobody registered. log-tail is
flagstaff's own, registered when flagstaff/log-tail is imported; a component of your own is
registered with flagstaff/plugin's register().
import { open, processRuntime } from 'controlroom';
import { register } from 'controlroom/plugin';
import 'flagstaff/log-tail'; // registers the `log-tail` component the `log` pane draws with
import vimKeys from './vim-keys.mjs';
register(vimKeys);
const screen = open(processRuntime(), { keymap: 'vim', layout: 'main', panes: { main: { pane: 'log', state: { lines: ['ready'] } } } });A screen takes a keymap by name (keymap: 'vim'), and a pane by name with its state. The
built-in default keymap is registered through the same register(); register your own
default to replace it.
Where it sits
Plugins register under the keymaps and panes keys, against the one schema the whole family shares.
Nothing in this family builds on it yet, and it builds on caique, closeout, flagstaff, linegauge, paratext, roundel.
The family
Ten 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.
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.
| Package | What it is | Migrates from |
|---|---|---|
| burgee | The CLI framework: one declaration, every surface | commander, yargs and meow |
| roundel | Colour: one output policy, semantic tokens, a theme | chalk and picocolors |
| flagstaff | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 |
| caique | Prompts that are flags first, and never hang | @inquirer/core |
| linegauge | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line |
| paratext | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img |
| seniority | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc |
| closeout | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime |
| bellpull | Subprocesses, and which executable actually ran | cross-spawn and which |
| controlroom (this package) | Keyboard-driven terminal screens, inline or full-screen | ink |
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.