burgee
Concepts

The static projection

Every live widget and every terminal capability in the family has a plain-text form for pipes, CI and screen readers — and one without it is refused when it is registered, not discovered when it prints.

What it is

Anything the family draws for a terminal — a spinner, a progress bar, a prompt, a hyperlink, an image — has two forms: the live one a terminal sees, and a static projection, plain text that says the same thing to a reader who cannot see the drawing. A pipe, a CI log, a screen reader and an agent get the static form. The output policy decides which form a run gets; this page is about what each form must be.

The static form is required. The live form is optional.

Why it exists

Stripping the escapes out of a drawing does not make it text. A progress bar with its colour removed is still a row of blocks; a box is still a border; a hyperlink with its OSC 8 sequence removed has lost its destination. The static form is written for a reader, which is why a built-in says 12/30 files · 40% rather than ████░░░░. And a rule every contributor has to remember is a rule some contribution breaks, so the family checks it at registration: a component, a widget or a capability without a text form is refused with one code, E_NO_STATIC_PROJECTION, before it can print anything.

How each package implements it

flagstaff: components

A component is { name, static(state), frame?(t, state), interval? }. hoist() asks outputMode once and picks a projection:

ModeWhat is writtenWhere
ttyframe(t, state) repainted every interval ms (80 by default), the cursor hidden; on lower(), the final static linestdout
pipe, ci, accessiblestatic(state), once per change of the text: no carriage return, no cursor escape, and a growing list appended rather than reprintedstdout
jsonone NDJSON event per transition, {"event":<name>,"state":<state>}stderr

Under --json stdout stays free for the program's own result. Pinned by R1 · one component, five modes and R5 · off a terminal, no carriage return and no cursor escape in loop.test.ts, and the refusal by R2 · a contribution without a static projection is refused in plugin.test.ts. The same applies to a spinner: its static string is what a pipe prints instead of the frames.

paratext: capabilities

A terminal capability is { name, osc, when, encode, fallback }. emit(runtime, name, fields) writes encode where when says the terminal supports it and fallback everywhere else: a link is the report (https://…) in a pipe, never raw OSC and never the text with its destination dropped. fallback is required; an empty string is a legal answer (the bell has nothing to say in a pipe), absence is not (refuses a capability with no projection, at registration rather than at output in paratext.test.ts).

paratext is a leaf, so it cannot ask roundel's outputMode. Its built-in capabilities declare when: { tty: true }, which is what sends a pipe to the fallback; TERM=dumb always does.

caique: prompts

A prompt is flags first. When an answer is missing and nobody can type it — --json, no terminal on stdin, CI, or an agent — caique does not wait: it refuses with a USAGE verdict that names the flag to pass, which burgee turns into exit 2. A plugin widget must carry static(spec), the line a pipe, an agent or a screen reader gets instead of the prompt (a widget without `static` is E_NO_STATIC_PROJECTION, the same code flagstaff uses (R5) in plugin.test.ts; R2 · with nobody there, a missing value is an error and never a wait in decide.test.ts).

controlroom

Reserved, with no API yet. Its spec plans the same rule for full-screen screens: each pane's static projection in pane order, and NDJSON events on stderr under --json.

What is not the same everywhere

Only flagstaff emits NDJSON under --json. caique's answer to --json is to refuse the prompt, which is an envelope rather than an event stream, and paratext has no --json form: it writes the fallback wherever when does not match. And paratext decides support from when alone, so a capability whose when matches a terminal is emitted in the accessible and ci modes too when stdout is that terminal.

Run it

One component and one capability, in a pipe and under --json:

project.mjs
import process from 'node:process';

import { hoist } from 'flagstaff/loop';
import { emit } from 'paratext';

const rt = {
  env: process.env,
  isTTY: { stdout: process.stdout.isTTY === true },
  stdout: process.stdout,
  stderr: process.stderr,
  clock: {
    now: () => Date.now(),
    schedule(fn, ms) {
      const id = setTimeout(fn, ms);
      return () => clearTimeout(id);
    },
  },
};
const json = process.argv.includes('--json');

const upload = {
  name: 'upload',
  static: (s) => `${s.sent} of ${s.total} files uploaded`,
  frame: (t, s) => `${'|/-\\'[Math.floor(t / 80) % 4]} ${s.sent}/${s.total}`,
};

const flag = hoist(upload, rt, { sent: 0, total: 2 }, { json });
flag.update({ sent: 1, total: 2 });
flag.lower({ sent: 2, total: 2 });

if (!json) console.log(emit(rt, 'link', { text: 'the report', url: 'https://example.com/r/42' }));
node project.mjs
0 of 2 files uploaded
1 of 2 files uploaded
2 of 2 files uploaded
the report (https://example.com/r/42)
CLI_ACCESSIBLE=1 node project.mjs
0 of 2 files uploaded
1 of 2 files uploaded
2 of 2 files uploaded
the report (https://example.com/r/42)
node project.mjs --json
{"event":"upload","state":{"sent":0,"total":2}}
{"event":"upload","state":{"sent":1,"total":2}}
{"event":"upload","state":{"sent":2,"total":2}}

A contribution without a text form, in each of the three packages that draw or emit, and a prompt with nobody to answer it:

refuse.mjs
import process from 'node:process';

import { decide } from 'caique/decide';
import { register as registerWidget } from 'caique/plugin';
import { register as registerComponent } from 'flagstaff/plugin';
import { register as registerCapability } from 'paratext';

const attempts = {
  flagstaff: () => registerComponent({ name: 'acme', spinners: { dots: { frames: ['.', '..'], interval: 100 } } }),
  caique: () => registerWidget({ name: 'acme', widgets: { rating: {} } }),
  paratext: () => registerCapability({ name: 'beep', osc: 'BEL', when: { tty: true }, encode: '\u0007' }),
};
for (const [pkg, attempt] of Object.entries(attempts)) {
  try {
    attempt();
  } catch (error) {
    console.log(`${pkg}: ${error.code}`);
  }
}

const verdict = decide({
  value: undefined,
  spec: { kind: 'text', message: 'Project name?' },
  option: 'name',
  runtime: { env: process.env, isTTY: { stdin: process.stdin.isTTY === true } },
  required: true,
});
console.log(verdict);
node refuse.mjs
flagstaff: E_NO_STATIC_PROJECTION
caique: E_NO_STATIC_PROJECTION
paratext: E_NO_STATIC_PROJECTION
{
  action: 'error',
  code: 'USAGE',
  message: '--name is required when there is no terminal',
  fix: 'pass --name; it would have been asked as "Project name?"'
}

Where the rules live

On this page