flagstaff 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { hoist, manualClock } from './loop.js';
5
+ import { PluginError, register, registered } from './plugin.js';
6
+ import { spinner } from './spinner.js';
7
+ const MODES = ['tty', 'pipe', 'ci', 'json', 'accessible'];
8
+ const ENV = { tty: {}, pipe: {}, ci: { CI: 'true' }, json: {}, accessible: { CLI_ACCESSIBLE: '1' } };
9
+ const LABEL_WIDTH = 12;
10
+ const FRAMES_SHOWN = 3;
11
+ const EXIT_USAGE = 2;
12
+ const KNOWN = ['$schema', 'name', 'contract', 'tokens', 'glyphs', 'spinners', 'borders', 'components'];
13
+ const KINDS = ['spinners', 'borders', 'components', 'glyphs', 'tokens'];
14
+ const ASSUMED = { running: { phase: 'running' }, done: { phase: 'done' } };
15
+ const SPIN = { running: { text: 'working' }, done: { text: 'done', status: 'ok' } };
16
+ const FIX_NO_CONTRIBUTION = 'flagstaff reads spinners, borders, components, glyphs and tokens; check those spellings. A key another package in the family reads is allowed in the same object, but `flagstaff check` cannot grade one.';
17
+ const FIX_THREW = '`static(state)` must return a string for the state it is rendered with; give the component a `sample: { running, done }` of the shape it expects';
18
+ const visible = (s) => s.replaceAll('\u001B', '␛').replaceAll('\r', '␍').replaceAll('\n', '⏎ ');
19
+ function render(component, mode, sample) {
20
+ const out = [];
21
+ const err = [];
22
+ const clock = manualClock();
23
+ const rt = { env: ENV[mode], isTTY: { stdout: mode === 'tty' }, stdout: { write: (s) => out.push(s) }, stderr: { write: (s) => err.push(s) }, clock };
24
+ try {
25
+ const flag = hoist(component, rt, sample.running, { json: mode === 'json' });
26
+ for (let i = 0; i < FRAMES_SHOWN; i += 1)
27
+ clock.tick(component.interval ?? 0);
28
+ flag.lower(sample.done);
29
+ }
30
+ catch (e) {
31
+ return { text: `threw: ${e instanceof Error ? e.message : String(e)}`, threw: true };
32
+ }
33
+ return { text: visible([...out, ...err].join('')), threw: false };
34
+ }
35
+ const row = (label, text) => ` ${label.padEnd(LABEL_WIDTH)}${text}\n`;
36
+ function show(title, component, { sample, note }, write) {
37
+ write(`${title}\n`);
38
+ if (note !== '')
39
+ write(row('sample', note));
40
+ const broke = [];
41
+ for (const mode of MODES) {
42
+ const { text, threw } = render(component, mode, sample);
43
+ if (threw)
44
+ broke.push(mode);
45
+ write(row(mode, text));
46
+ }
47
+ return broke;
48
+ }
49
+ function census(plugin) {
50
+ let total = 0;
51
+ const parts = KINDS.map((kind) => {
52
+ const n = Object.keys((plugin[kind] ?? {})).length;
53
+ total += n;
54
+ return `${n} ${n === 1 ? kind.slice(0, -1) : kind}`;
55
+ });
56
+ return { line: parts.join(', '), total };
57
+ }
58
+ function refuse(code, message, fix, write) {
59
+ write(`${code}: ${message}\n fix: ${fix}\n`);
60
+ return 1;
61
+ }
62
+ function sampleFor(def) {
63
+ const own = def.sample;
64
+ if (own === undefined)
65
+ return { sample: ASSUMED, note: `assumed ${JSON.stringify(ASSUMED)} — give the component a \`sample\` to choose its own` };
66
+ return { sample: own, note: `from the component: ${describe(own)}` };
67
+ }
68
+ function describe(value) {
69
+ try {
70
+ return JSON.stringify(value) ?? String(value);
71
+ }
72
+ catch {
73
+ return '(not serializable — shown as given)';
74
+ }
75
+ }
76
+ async function main(argv, write) {
77
+ const file = argv[0];
78
+ if (file === undefined) {
79
+ write('usage: flagstaff check <plugin-file>\n');
80
+ return EXIT_USAGE;
81
+ }
82
+ const before = new Set(registered().plugins);
83
+ const loaded = (await import(pathToFileURL(resolve(file)).href));
84
+ const plugin = loaded.default ?? loaded;
85
+ try {
86
+ register(plugin);
87
+ }
88
+ catch (e) {
89
+ if (!(e instanceof PluginError))
90
+ throw e;
91
+ return refuse(e.code, e.message, e.fix, write);
92
+ }
93
+ const { name, spinners = {}, components = {} } = plugin;
94
+ const { line, total } = census(plugin);
95
+ write(`${name} — ${line}\n`);
96
+ const unknown = Object.keys(plugin)
97
+ .filter((key) => !KNOWN.includes(key))
98
+ .sort();
99
+ if (unknown.length > 0)
100
+ write(row('unknown', `${unknown.join(', ')} — flagstaff reads none of these; a key another package in the family reads is allowed here`));
101
+ if (total === 0)
102
+ return refuse('E_NO_CONTRIBUTION', `${name} registers, but contributes nothing flagstaff can render`, FIX_NO_CONTRIBUTION, write);
103
+ const broke = [];
104
+ for (const style of Object.keys(spinners)) {
105
+ const modes = show(`spinner ${style}`, spinner(style), { sample: SPIN, note: '' }, write);
106
+ if (modes.length > 0)
107
+ broke.push(`spinner ${style} threw in ${modes.join(', ')}`);
108
+ }
109
+ for (const [cname, def] of Object.entries(components)) {
110
+ const modes = show(`component ${cname}`, { ...def, name: cname }, sampleFor(def), write);
111
+ if (modes.length > 0)
112
+ broke.push(`${cname} threw in ${modes.join(', ')}`);
113
+ }
114
+ if (broke.length > 0)
115
+ return refuse('E_COMPONENT_THREW', broke.join('; '), FIX_THREW, write);
116
+ write(`${name}: ok${before.has(name) ? ' (replaces an earlier registration)' : ''}\n`);
117
+ return 0;
118
+ }
119
+ const [, , command, ...rest] = process.argv;
120
+ const args = command === 'check' ? rest : [command, ...rest].filter((a) => a !== undefined);
121
+ main(args, (s) => process.stdout.write(s)).then((code) => {
122
+ process.exitCode = code;
123
+ }, (e) => {
124
+ process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
125
+ process.exitCode = 1;
126
+ });
@@ -0,0 +1,13 @@
1
+ export declare const HIDE_CURSOR = "\u001B[?25l";
2
+ export declare const SHOW_CURSOR = "\u001B[?25h";
3
+ /**
4
+ * Installed once, the first time a cursor is hidden; puts it back however the process dies.
5
+ *
6
+ * `write` is how the caller's own surface reaches the terminal, and passing it is what makes
7
+ * this usable from the core. `hoist()` already knows its stream — the Runtime gave it one and
8
+ * the output mode was decided from it once (R1/U2) — so it must not be re-detected here: a
9
+ * second detector is precisely what the policy exists to prevent. The façades pass nothing
10
+ * and get the detection below, because that is their incumbents' contract — ora and
11
+ * log-update restore *the process's* cursor whichever stream the caller handed them.
12
+ */
13
+ export declare function restoreCursorOnExit(write?: (s: string) => void): () => void;
package/dist/cursor.js ADDED
@@ -0,0 +1,49 @@
1
+ import { constants } from 'node:os';
2
+ import process from 'node:process';
3
+ export const HIDE_CURSOR = '\u001B[?25l';
4
+ export const SHOW_CURSOR = '\u001B[?25h';
5
+ const TERMINATION_SIGNALS = ['SIGHUP', 'SIGINT', 'SIGTERM', 'SIGBREAK'].filter((signal) => signal in constants.signals);
6
+ function terminalStream() {
7
+ if (process.stderr.isTTY)
8
+ return process.stderr;
9
+ if (process.stdout.isTTY)
10
+ return process.stdout;
11
+ return undefined;
12
+ }
13
+ let cursorRestoreInstalled = false;
14
+ const NOTHING_TO_UNDO = () => undefined;
15
+ export function restoreCursorOnExit(write) {
16
+ if (cursorRestoreInstalled)
17
+ return NOTHING_TO_UNDO;
18
+ cursorRestoreInstalled = true;
19
+ const terminal = write === undefined ? terminalStream() : { write };
20
+ if (terminal === undefined)
21
+ return NOTHING_TO_UNDO;
22
+ let restored = false;
23
+ const restore = () => {
24
+ if (restored)
25
+ return;
26
+ restored = true;
27
+ terminal.write(SHOW_CURSOR);
28
+ };
29
+ const installed = new Map();
30
+ const uninstall = () => {
31
+ for (const [name, fn] of installed)
32
+ process.removeListener(name, fn);
33
+ installed.clear();
34
+ process.removeListener('exit', restore);
35
+ cursorRestoreInstalled = false;
36
+ };
37
+ for (const signal of TERMINATION_SIGNALS) {
38
+ const handler = () => {
39
+ restore();
40
+ uninstall();
41
+ if (process.listenerCount(signal) === 0)
42
+ process.kill(process.pid, signal);
43
+ };
44
+ process.on(signal, handler);
45
+ installed.set(signal, handler);
46
+ }
47
+ process.once('exit', restore);
48
+ return uninstall;
49
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `flagstaff/import` (R11) — the two data corpora the ecosystem already has, as plugins.
3
+ *
4
+ * cli-spinners ships ~80 spinner styles and cli-boxes ships eight border sets, both as
5
+ * plain JSON that thousands of programs already depend on. Neither is bundled here: the
6
+ * weight of a corpus nobody asked for is exactly what U5 is about, and a caller who wants
7
+ * one already has it on disk. So these take the JSON and hand back a plugin:
8
+ *
9
+ * ```js
10
+ * import cliSpinners from 'cli-spinners';
11
+ * import { fromCliSpinners } from 'flagstaff/import';
12
+ * import { register } from 'flagstaff/plugin';
13
+ *
14
+ * register(fromCliSpinners(cliSpinners));
15
+ * spinner('moon');
16
+ * ```
17
+ *
18
+ * The result is an ordinary plugin — it goes through the same `register()`, is validated
19
+ * against the same schema, and can be replaced by another. That is the point: the gallery
20
+ * opens full, and a third-party plugin starts as a copy of one of these.
21
+ */
22
+ import { type BorderStyle, type Plugin } from './plugin.js';
23
+ /** cli-spinners' shape: no `static`, because it has no projection for a pipe. */
24
+ export interface CliSpinner {
25
+ frames: string[];
26
+ interval?: number;
27
+ }
28
+ /** cli-boxes' shape — the same eight keys `BorderStyle` has, which is why it imports whole. */
29
+ export type CliBox = BorderStyle;
30
+ export interface FromCliSpinnersOptions {
31
+ /** The plugin's name, which is what `flagstaff check` and the gallery label it with. */
32
+ name?: string;
33
+ /**
34
+ * The static projection for a style, given its name and its frames. Default: `'…'` for
35
+ * every one of them.
36
+ *
37
+ * The design sketched "the first frame" here, and that turned out to be wrong when it met
38
+ * the corpus: a frozen `⠋` or `▰` is an animation stopped mid-stride, not a projection —
39
+ * it tells a pipe, an agent and a screen reader nothing that `…` does not tell them
40
+ * better. The parameter exists because it is the author's call, not this function's.
41
+ */
42
+ staticFor?: (name: string, spinner: CliSpinner) => string;
43
+ }
44
+ /** Turn cli-spinners' `spinners.json` into a plugin. The corpus stays the caller's. */
45
+ export declare function fromCliSpinners(corpus: Record<string, CliSpinner>, { name, staticFor }?: FromCliSpinnersOptions): Plugin;
46
+ export interface FromCliBoxesOptions {
47
+ name?: string;
48
+ }
49
+ /** Turn cli-boxes' `boxes.json` into a plugin. Its shape is already ours, so this is a copy. */
50
+ export declare function fromCliBoxes(corpus: Record<string, CliBox>, { name }?: FromCliBoxesOptions): Plugin;
package/dist/import.js ADDED
@@ -0,0 +1,20 @@
1
+ const DEFAULT_INTERVAL = 80;
2
+ const DEFAULT_STATIC = '…';
3
+ export function fromCliSpinners(corpus, { name = 'cli-spinners', staticFor = () => DEFAULT_STATIC } = {}) {
4
+ const spinners = {};
5
+ for (const [style, spinner] of Object.entries(corpus)) {
6
+ if (!Array.isArray(spinner?.frames) || spinner.frames.length === 0)
7
+ continue;
8
+ spinners[style] = { frames: spinner.frames, interval: spinner.interval ?? DEFAULT_INTERVAL, static: staticFor(style, spinner) };
9
+ }
10
+ return { name, contract: 1, spinners };
11
+ }
12
+ export function fromCliBoxes(corpus, { name = 'cli-boxes' } = {}) {
13
+ const borders = {};
14
+ for (const [style, box] of Object.entries(corpus)) {
15
+ if (typeof box?.topLeft !== 'string')
16
+ continue;
17
+ borders[style] = box;
18
+ }
19
+ return { name, contract: 1, borders };
20
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
- * flagstaff — reserved. The first working release follows
3
- * docs/intents/cli-render/ in the burgee repository. Until then this entry
4
- * exports only its own name, so that importing it costs nothing and promises nothing.
2
+ * flagstaff — the staff the flag flies from. Re-exports only; each subpath is its own entry
3
+ * and costs only itself, so prefer `flagstaff/spinner` when that is all you need.
5
4
  */
6
- export declare const name: "flagstaff";
5
+ export * from './box.js';
6
+ export * from './import.js';
7
+ export * from './loop.js';
8
+ export * from './plugin.js';
9
+ export * from './progress.js';
10
+ export * from './spinner.js';
11
+ export * from './table.js';
12
+ export * from './tasks.js';
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
- /**
2
- * flagstaff — reserved. The first working release follows
3
- * docs/intents/cli-render/ in the burgee repository. Until then this entry
4
- * exports only its own name, so that importing it costs nothing and promises nothing.
5
- */
6
- export const name = 'flagstaff';
7
- //# sourceMappingURL=index.js.map
1
+ export * from './box.js';
2
+ export * from './import.js';
3
+ export * from './loop.js';
4
+ export * from './plugin.js';
5
+ export * from './progress.js';
6
+ export * from './spinner.js';
7
+ export * from './table.js';
8
+ export * from './tasks.js';
@@ -0,0 +1,29 @@
1
+ /** What log-update writes to: `process.stdout` by default, anything stream-shaped in a test. */
2
+ export interface LogUpdateStream {
3
+ write(chunk: string): unknown;
4
+ columns?: number;
5
+ rows?: number;
6
+ isTTY?: boolean;
7
+ }
8
+ export interface LogUpdateOptions {
9
+ /** Leave the cursor visible while a frame is up. Default false. */
10
+ showCursor?: boolean;
11
+ /** Columns to assume when the stream reports none. Default 80. */
12
+ defaultWidth?: number;
13
+ /** Rows to assume when the stream reports none. Default 24. */
14
+ defaultHeight?: number;
15
+ }
16
+ export interface LogUpdate {
17
+ (...text: unknown[]): void;
18
+ /** Erase the current frame, leaving nothing behind. */
19
+ clear(): void;
20
+ /** Give up the rows: the frame stays on screen and the next render starts fresh. */
21
+ done(): void;
22
+ /** Replace the frame with text that stays, unclipped, and start fresh after it. */
23
+ persist(...text: unknown[]): void;
24
+ }
25
+ /** A renderer bound to one stream. `logUpdate` is this over `process.stdout`. */
26
+ export declare function createLogUpdate(stream: LogUpdateStream, { showCursor: keepCursor, defaultWidth, defaultHeight }?: LogUpdateOptions): LogUpdate;
27
+ declare const logUpdate: LogUpdate;
28
+ export declare const logUpdateStderr: LogUpdate;
29
+ export default logUpdate;
@@ -0,0 +1,173 @@
1
+ import process from 'node:process';
2
+ import { HIDE_CURSOR, restoreCursorOnExit, SHOW_CURSOR } from './cursor.js';
3
+ import { wrap } from './wrap.js';
4
+ const CSI = '\u001B[';
5
+ const SYNCHRONIZED_OUTPUT_ENABLE = `${CSI}?2026h`;
6
+ const SYNCHRONIZED_OUTPUT_DISABLE = `${CSI}?2026l`;
7
+ const CURSOR_LEFT = `${CSI}G`;
8
+ const ERASE_LINE = `${CSI}2K`;
9
+ const ERASE_END_LINE = `${CSI}K`;
10
+ const DEFAULT_WIDTH = 80;
11
+ const DEFAULT_HEIGHT = 24;
12
+ const cursorUp = (count = 1) => `${CSI}${count}A`;
13
+ const cursorDown = (count = 1) => `${CSI}${count}B`;
14
+ const eraseLines = (count) => {
15
+ let sequence = '';
16
+ for (let index = 0; index < count; index += 1)
17
+ sequence += ERASE_LINE + (index < count - 1 ? cursorUp() : '');
18
+ return count > 0 ? sequence + CURSOR_LEFT : sequence;
19
+ };
20
+ function hideCursor() {
21
+ if (process.stderr.isTTY !== true)
22
+ return;
23
+ restoreCursorOnExit();
24
+ process.stderr.write(HIDE_CURSOR);
25
+ }
26
+ function showCursor() {
27
+ if (process.stderr.isTTY !== true)
28
+ return;
29
+ process.stderr.write(SHOW_CURSOR);
30
+ }
31
+ const countLines = (text) => text.split('\n').length;
32
+ function fitToHeight(wrapped, terminalHeight) {
33
+ if (terminalHeight === 0)
34
+ return { text: '', wasClipped: wrapped !== '' };
35
+ const lines = wrapped.split('\n');
36
+ const toRemove = Math.max(0, lines.length - terminalHeight);
37
+ if (toRemove === 0)
38
+ return { text: wrapped, wasClipped: false };
39
+ return { text: lines.slice(toRemove).join('\n'), wasClipped: true };
40
+ }
41
+ function diffFrames(previousLines, nextLines) {
42
+ let start = 0;
43
+ while (start < previousLines.length && start < nextLines.length && previousLines[start] === nextLines[start])
44
+ start += 1;
45
+ let endPrevious = previousLines.length - 1;
46
+ let endNext = nextLines.length - 1;
47
+ while (endPrevious >= start && endNext >= start && previousLines[endPrevious] === nextLines[endNext]) {
48
+ endPrevious -= 1;
49
+ endNext -= 1;
50
+ }
51
+ return { start, endPrevious, endNext };
52
+ }
53
+ function moveToRow(from, to) {
54
+ const delta = to - from;
55
+ if (delta > 0)
56
+ return cursorDown(delta);
57
+ if (delta < 0)
58
+ return cursorUp(-delta);
59
+ return '';
60
+ }
61
+ function buildPatch({ previousCount, start, endPrevious, endNext, nextLines, endsWithNewline }) {
62
+ let sequence = moveToRow(previousCount - 1, start) + CURSOR_LEFT;
63
+ const linesToClear = Math.max(0, endPrevious - start + 1);
64
+ for (let index = 0; index < linesToClear; index += 1)
65
+ sequence += ERASE_LINE + (index < linesToClear - 1 ? cursorDown() : '');
66
+ if (linesToClear > 1)
67
+ sequence += cursorUp(linesToClear - 1);
68
+ sequence += CURSOR_LEFT;
69
+ const wrote = nextLines.slice(start, endNext + 1);
70
+ let writtenLineBreaks = 0;
71
+ if (wrote.length > 0) {
72
+ const chunk = wrote.join('\n');
73
+ const trailingNewline = endsWithNewline && endNext < nextLines.length - 1 && !chunk.endsWith('\n');
74
+ sequence += chunk;
75
+ writtenLineBreaks = countLines(chunk) - 1;
76
+ sequence += ERASE_END_LINE;
77
+ if (trailingNewline) {
78
+ sequence += '\n';
79
+ writtenLineBreaks += 1;
80
+ }
81
+ }
82
+ return sequence + moveToRow(start + writtenLineBreaks, nextLines.length - 1);
83
+ }
84
+ export function createLogUpdate(stream, { showCursor: keepCursor = false, defaultWidth, defaultHeight } = {}) {
85
+ const widthOf = () => stream.columns ?? defaultWidth ?? DEFAULT_WIDTH;
86
+ const heightOf = () => stream.rows ?? defaultHeight ?? DEFAULT_HEIGHT;
87
+ let previousLineCount = 0;
88
+ let previousWidth = widthOf();
89
+ let previousOutput = '';
90
+ const useSynchronizedOutput = stream.isTTY === true;
91
+ const write = (output) => {
92
+ if (output === '')
93
+ return;
94
+ stream.write(useSynchronizedOutput ? SYNCHRONIZED_OUTPUT_ENABLE + output + SYNCHRONIZED_OUTPUT_DISABLE : output);
95
+ };
96
+ const computeFrame = (text, columns, clipToHeight = true) => {
97
+ const textString = String(text);
98
+ const raw = textString.endsWith('\n') ? textString : `${textString}\n`;
99
+ const wrapped = wrap(raw, columns, { trim: false, hard: true, wordWrap: false });
100
+ const { text: frameText, wasClipped } = clipToHeight ? fitToHeight(wrapped, heightOf()) : { text: wrapped, wasClipped: false };
101
+ return { wrapped: frameText, lines: frameText === '' ? [] : frameText.split('\n'), wasClipped };
102
+ };
103
+ const reset = () => {
104
+ previousOutput = '';
105
+ previousWidth = widthOf();
106
+ previousLineCount = 0;
107
+ };
108
+ const remember = (frame, columns) => {
109
+ previousOutput = frame.wrapped;
110
+ previousWidth = columns;
111
+ previousLineCount = frame.lines.length;
112
+ };
113
+ const render = (...args) => {
114
+ if (!keepCursor)
115
+ hideCursor();
116
+ const columns = widthOf();
117
+ const frame = computeFrame(args.join(' '), columns);
118
+ const { wrapped, lines, wasClipped } = frame;
119
+ if (lines.length === 0) {
120
+ if (previousLineCount > 0)
121
+ write(eraseLines(previousLineCount));
122
+ remember(frame, columns);
123
+ return;
124
+ }
125
+ if (wrapped === previousOutput && previousWidth === columns)
126
+ return;
127
+ if (previousLineCount === 0) {
128
+ write(wrapped);
129
+ remember(frame, columns);
130
+ return;
131
+ }
132
+ if (previousWidth !== columns || wasClipped) {
133
+ write(eraseLines(previousLineCount) + wrapped);
134
+ remember(frame, columns);
135
+ return;
136
+ }
137
+ const previousLines = previousOutput === '' ? [] : previousOutput.split('\n');
138
+ let { start, endPrevious, endNext } = diffFrames(previousLines, lines);
139
+ if (previousLines.length !== lines.length) {
140
+ endPrevious = previousLines.length - 1;
141
+ endNext = lines.length - 1;
142
+ }
143
+ if (start === lines.length && previousLineCount === lines.length)
144
+ return;
145
+ if (start === 0) {
146
+ write(eraseLines(previousLineCount) + wrapped);
147
+ remember(frame, columns);
148
+ return;
149
+ }
150
+ write(buildPatch({ previousCount: previousLineCount, start, endPrevious, endNext, nextLines: lines, endsWithNewline: wrapped.endsWith('\n') }));
151
+ remember(frame, columns);
152
+ };
153
+ render.clear = () => {
154
+ write(eraseLines(previousLineCount));
155
+ reset();
156
+ };
157
+ render.done = () => {
158
+ reset();
159
+ if (!keepCursor)
160
+ showCursor();
161
+ };
162
+ render.persist = (...args) => {
163
+ const erasePrevious = previousLineCount > 0 ? eraseLines(previousLineCount) : '';
164
+ const columns = widthOf();
165
+ const { wrapped } = computeFrame(args.join(' '), columns, false);
166
+ write(erasePrevious + wrapped);
167
+ reset();
168
+ };
169
+ return render;
170
+ }
171
+ const logUpdate = createLogUpdate(process.stdout);
172
+ export const logUpdateStderr = createLogUpdate(process.stderr);
173
+ export default logUpdate;
package/dist/loop.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The frame loop (R1). `hoist()` raises a component, `update()` changes its state, `lower()`
3
+ * takes it down and leaves the static line behind. Which of the four projections runs is the
4
+ * output policy's decision (`roundel/policy`), read once from the runtime; this file never
5
+ * looks at a stream's `isTTY` or an environment variable itself (U2).
6
+ */
7
+ import { type ModeOptions, type OutputMode, type Runtime as PolicyRuntime } from 'roundel/policy';
8
+ import { type Component } from './plugin.js';
9
+ import { type Clock, type Writer } from './projection.js';
10
+ /** What the loop needs from the world: the policy's slice plus the two streams and a clock. */
11
+ export interface Runtime extends PolicyRuntime {
12
+ stdout: Writer;
13
+ stderr: Writer;
14
+ clock: Clock;
15
+ }
16
+ export interface Hoisted<S> {
17
+ readonly mode: OutputMode;
18
+ update(state: S): void;
19
+ /** Lower the flag: the final state's static projection is what stays on screen. */
20
+ lower(state?: S): void;
21
+ }
22
+ /** Hoist a component in its initial state. Pass `{ json: true }` when the run was asked for `--json`. */
23
+ export declare function hoist<S>(component: Component<S>, rt: Runtime, initial: S, opts?: ModeOptions): Hoisted<S>;
24
+ export interface ManualClock extends Clock {
25
+ /** Advance by `ms`, running every callback that falls due, earliest first. */
26
+ tick(ms: number): void;
27
+ }
28
+ /** A clock that moves only when told (R9): the same frames every run, so a spinner snapshots. */
29
+ export declare function manualClock(start?: number): ManualClock;
30
+ export type { Clock, Writer } from './projection.js';
package/dist/loop.js ADDED
@@ -0,0 +1,67 @@
1
+ import { outputMode } from 'roundel/policy';
2
+ import { jsonProjection, staticProjection, ttyProjection } from './projection.js';
3
+ function projectionFor(mode, component, rt) {
4
+ if (mode === 'tty')
5
+ return ttyProjection(component, rt.stdout, rt.clock);
6
+ if (mode === 'json')
7
+ return jsonProjection(component, rt.stderr);
8
+ return staticProjection(component, rt.stdout);
9
+ }
10
+ export function hoist(component, rt, initial, opts = {}) {
11
+ const mode = outputMode(rt, opts);
12
+ const projection = projectionFor(mode, component, rt);
13
+ let state = initial;
14
+ let lowered = false;
15
+ projection.open(state);
16
+ return {
17
+ mode,
18
+ update(next) {
19
+ if (lowered)
20
+ return;
21
+ state = next;
22
+ projection.change(state);
23
+ },
24
+ lower(final = state) {
25
+ if (lowered)
26
+ return;
27
+ lowered = true;
28
+ state = final;
29
+ projection.close(state);
30
+ },
31
+ };
32
+ }
33
+ const TICK_CAP = 1000;
34
+ function nextDue(timers, until) {
35
+ return timers.filter((t) => t.at <= until).sort((a, b) => a.at - b.at || a.id - b.id)[0];
36
+ }
37
+ export function manualClock(start = 0) {
38
+ let now = start;
39
+ let nextId = 0;
40
+ let timers = [];
41
+ return {
42
+ now: () => now,
43
+ schedule(fn, ms) {
44
+ const id = nextId++;
45
+ timers.push({ id, at: now + Math.max(0, ms), fn });
46
+ return () => {
47
+ timers = timers.filter((t) => t.id !== id);
48
+ };
49
+ },
50
+ tick(ms) {
51
+ const until = now + ms;
52
+ let ran = 0;
53
+ let due = nextDue(timers, until);
54
+ while (due !== undefined && ran < TICK_CAP) {
55
+ const { id, at, fn } = due;
56
+ timers = timers.filter((t) => t.id !== id);
57
+ now = Math.max(now, at);
58
+ fn();
59
+ ran += 1;
60
+ due = nextDue(timers, until);
61
+ }
62
+ if (due !== undefined)
63
+ throw new Error(`manualClock: ${TICK_CAP} callbacks in one tick; something reschedules itself at 0 ms`);
64
+ now = until;
65
+ },
66
+ };
67
+ }