controlroom

Getting started

Install controlroom, open a screen with two panes, and see the same program on a terminal, in a pipe and under --json.

controlroom draws a screen: panes laid out in rows and columns, each pane a flagstaff component with its state. You open a screen, update a pane's state as work moves, and close it. Whether that is a live screen redrawn in place or a stream of plain lines is decided once, when you open it, from the process it runs in.

Install

npm install controlroom flagstaff

controlroom installs six packages from this repository (caique, closeout, flagstaff, linegauge, paratext and roundel) and nothing from outside it. You install flagstaff yourself because your program imports its components. react and react-reconciler are optional peers, and only controlroom/ink loads them; the API on this page never does. It runs on Node 20.19+ and 22.13+.

A first screen

A deploy with a checklist on the left and a log on the right:

deploy.mjs
import { setTimeout as sleep } from 'node:timers/promises';

import { open, processRuntime } from 'controlroom';
import { logTail } from 'flagstaff/log-tail';
import { tasks } from 'flagstaff/tasks';

const steps = [
  { title: 'Install', status: 'ok' },
  { title: 'Build', status: 'running' },
  { title: 'Upload', status: 'pending' },
];
const lines = ['added 12 packages'];

const screen = open(processRuntime(), {
  json: process.argv.includes('--json'),
  layout: {
    direction: 'row',
    parts: [
      { size: { fr: 1, min: 20 }, content: 'steps' },
      { size: { fr: 2 }, content: 'log' },
    ],
  },
  panes: {
    steps: { component: tasks(), state: { tasks: steps }, label: 'Steps' },
    log: { component: logTail({ height: 6 }), state: { lines }, label: 'Log' },
  },
});

/** Settle the running step, start the next one, and log what happened. */
function advance(line) {
  const running = steps.findIndex((s) => s.status === 'running');
  steps[running].status = 'ok';
  if (running + 1 < steps.length) steps[running + 1].status = 'running';
  lines.push(line);
  screen.update('steps', { tasks: steps });
  screen.update('log', { lines });
}

await sleep(300);
advance('built dist/ in 1.2s');
await sleep(300);
advance('uploaded 3 files');
screen.commit('Deployed.');
screen.close();

processRuntime() is the real process as controlroom reads it: the environment, the terminal, the streams and a clock. In a test you pass a literal instead, and the screen becomes a fixed transcript.

On a terminal, the two panes are drawn side by side under your prompt and redrawn in place as the steps move. commit() writes Deployed. above the live region, into the scrollback, and close() prints the live region's final state once, as its static projection, and hands the terminal back.

Pipe it, and the same program prints each pane under its label, then only the lines that are new since it last printed:

node deploy.mjs
Steps
✔ Install
Log
┊ added 12 packages
✔ Build
┊ built dist/ in 1.2s
✔ Upload
┊ uploaded 3 files
Deployed.

There is no escape byte, no carriage return and no cursor movement in it. Ask for --json, and stdout stays empty while stderr carries one NDJSON event per change, named after the pane:

node deploy.mjs --json
{"event":"steps","state":{"tasks":[{"title":"Install","status":"ok"},{"title":"Build","status":"running"},{"title":"Upload","status":"pending"}]}}
{"event":"log","state":{"lines":["added 12 packages"]}}
{"event":"steps","state":{"tasks":[{"title":"Install","status":"ok"},{"title":"Build","status":"ok"},{"title":"Upload","status":"running"}]}}
{"event":"log","state":{"lines":["added 12 packages","built dist/ in 1.2s"]}}
{"event":"steps","state":{"tasks":[{"title":"Install","status":"ok"},{"title":"Build","status":"ok"},{"title":"Upload","status":"ok"}]}}
{"event":"log","state":{"lines":["added 12 packages","built dist/ in 1.2s","uploaded 3 files"]}}
{"event":"commit","state":"Deployed."}
{"event":"steps","state":{"tasks":[{"title":"Install","status":"ok"},{"title":"Build","status":"ok"},{"title":"Upload","status":"ok"}]}}
{"event":"log","state":{"lines":["added 12 packages","built dist/ in 1.2s","uploaded 3 files"]}}

close() sends each pane's last state once more, so an agent sees when the screen closed and not only what it last showed. Every output block on this site is checked: tests/examples.test.ts writes each titled file, runs the command in the block's title, and compares.

Which screen a program gets

open() asks roundel's output policy, the rule every package in the family asks, for one of five modes:

modechosen whenwhat the screen does
jsonthe program passed json: trueone NDJSON event per change, on stderr
accessibleCLI_ACCESSIBLE is seteach pane's text once per change, never a redraw
ttystdout is a terminalthe live screen, if stdin can go raw too
ciCI is set and stdout is not a terminaleach pane's text once per change
pipeanything elseeach pane's text once per change

The first row that matches wins. Keys are read only in tty mode, and only when stdin is a terminal that can go raw; node deploy.mjs < input.txt in a terminal gets the static screen, because nobody can press a key into a file. That is the whole of the rule that a screen never waits for a key nobody can press.

Where next

On this page