@axplusb/kepler 1.0.10 → 2.0.2

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.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Kepler palette — semantic color tokens for the CLI.
3
+ *
4
+ * Every feature module imports from here, never from raw ANSI. Tokens resolve
5
+ * at call time so changing terminal capabilities (resize, `refresh()`) is
6
+ * picked up without restarting the process.
7
+ *
8
+ * import { paint } from './palette.mjs';
9
+ * process.stdout.write(paint.brand.primary('KEPLER'));
10
+ *
11
+ * Composition (multiple styles on one string):
12
+ *
13
+ * paint.bold(paint.brand.primary('KEPLER'))
14
+ *
15
+ * Tier behavior:
16
+ * truecolor → 24-bit RGB
17
+ * ansi256 → nearest 256-color index
18
+ * ansi16 → nearest basic color
19
+ * none → identity (input returned unchanged)
20
+ *
21
+ * Brand identity (Mission Control PRD-055):
22
+ * primary Deep Space Purple #7c3aed
23
+ * accent Stellar Magenta #ec4899
24
+ * data Neon Cyan #22d3ee
25
+ * success Aligned green #22c55e
26
+ * warn Soft amber #eab308
27
+ * danger Failure red #ef4444
28
+ * dim Sub-agent / hint #6b7280
29
+ * text Primary text #c9d1d9
30
+ */
31
+
32
+ import { term } from './term.mjs';
33
+
34
+ const ESC = '\x1b[';
35
+ const RESET = `${ESC}0m`;
36
+
37
+ // ── Brand tokens ─────────────────────────────────────────────────────────
38
+ // Each token is { rgb: [r,g,b], ansi256: n, ansi16: 'fgName' }.
39
+ // `ansi16` maps to a key in BASIC_FG below.
40
+
41
+ export const TOKENS = Object.freeze({
42
+ // Brand
43
+ 'brand.primary': { rgb: [124, 58, 237], ansi256: 99, ansi16: 'magenta' }, // #7c3aed
44
+ 'brand.accent': { rgb: [236, 72, 153], ansi256: 198, ansi16: 'magenta' }, // #ec4899
45
+ 'brand.data': { rgb: [34, 211, 238], ansi256: 87, ansi16: 'cyan' }, // #22d3ee
46
+
47
+ // State
48
+ 'state.success': { rgb: [34, 197, 94], ansi256: 41, ansi16: 'green' }, // #22c55e
49
+ 'state.warn': { rgb: [234, 179, 8], ansi256: 220, ansi16: 'yellow' }, // #eab308
50
+ 'state.danger': { rgb: [239, 68, 68], ansi256: 196, ansi16: 'red' }, // #ef4444
51
+
52
+ // Text
53
+ 'text.primary': { rgb: [201, 209, 217], ansi256: 250, ansi16: 'white' }, // #c9d1d9
54
+ 'text.dim': { rgb: [107, 114, 128], ansi256: 245, ansi16: 'gray' }, // #6b7280
55
+ 'text.muted': { rgb: [156, 163, 175], ansi256: 247, ansi16: 'gray' }, // #9ca3af
56
+ });
57
+
58
+ // ── ANSI 16-color foreground codes ───────────────────────────────────────
59
+
60
+ const BASIC_FG = {
61
+ black: 30,
62
+ red: 31,
63
+ green: 32,
64
+ yellow: 33,
65
+ blue: 34,
66
+ magenta: 35,
67
+ cyan: 36,
68
+ white: 37,
69
+ gray: 90,
70
+ };
71
+
72
+ // ── Style codes (work at every tier) ─────────────────────────────────────
73
+
74
+ const STYLE_CODES = {
75
+ bold: [1, 22],
76
+ dim: [2, 22],
77
+ italic: [3, 23],
78
+ underline: [4, 24],
79
+ inverse: [7, 27],
80
+ strike: [9, 29],
81
+ };
82
+
83
+ // ── Open / close sequence builders ───────────────────────────────────────
84
+
85
+ function openForToken(token, capability) {
86
+ const def = TOKENS[token];
87
+ if (!def) return '';
88
+
89
+ if (capability === 'truecolor') {
90
+ const [r, g, b] = def.rgb;
91
+ return `${ESC}38;2;${r};${g};${b}m`;
92
+ }
93
+ if (capability === 'ansi256') {
94
+ return `${ESC}38;5;${def.ansi256}m`;
95
+ }
96
+ if (capability === 'ansi16') {
97
+ return `${ESC}${BASIC_FG[def.ansi16] || BASIC_FG.white}m`;
98
+ }
99
+ return '';
100
+ }
101
+
102
+ function wrap(open) {
103
+ if (!open) return (input) => String(input ?? '');
104
+ // Re-open after every embedded reset so nested styles compose.
105
+ // Cheap and predictable; most tool output is short enough that the cost
106
+ // is negligible compared to writing to the TTY.
107
+ return (input) => {
108
+ const text = String(input ?? '');
109
+ if (!text) return '';
110
+ if (!text.includes(RESET)) return `${open}${text}${RESET}`;
111
+ return `${open}${text.split(RESET).join(`${RESET}${open}`)}${RESET}`;
112
+ };
113
+ }
114
+
115
+ function styleWrap(openCode, closeCode) {
116
+ return (input) => {
117
+ const text = String(input ?? '');
118
+ if (!text) return '';
119
+ // No color tier check here — styles like bold/dim work even in ansi16.
120
+ if (!term().color) return text;
121
+ const open = `${ESC}${openCode}m`;
122
+ const close = `${ESC}${closeCode}m`;
123
+ if (!text.includes(close)) return `${open}${text}${close}`;
124
+ return `${open}${text.split(close).join(`${close}${open}`)}${close}`;
125
+ };
126
+ }
127
+
128
+ // ── Build a structured `paint` object once per token ─────────────────────
129
+
130
+ function buildPaint() {
131
+ const paint = {};
132
+
133
+ // Brand / state / text colorizers, nested by namespace.
134
+ for (const token of Object.keys(TOKENS)) {
135
+ const [ns, name] = token.split('.');
136
+ if (!paint[ns]) paint[ns] = {};
137
+ paint[ns][name] = (input) => {
138
+ const t = term();
139
+ if (!t.color) return String(input ?? '');
140
+ return wrap(openForToken(token, t.colorLevel))(input);
141
+ };
142
+ }
143
+
144
+ // Style colorizers (callable directly).
145
+ for (const [style, [open, close]] of Object.entries(STYLE_CODES)) {
146
+ paint[style] = styleWrap(open, close);
147
+ }
148
+
149
+ // Compose helper — apply multiple styles left-to-right.
150
+ paint.compose = (...fns) => (input) =>
151
+ fns.reduce((acc, fn) => (typeof fn === 'function' ? fn(acc) : acc), input);
152
+
153
+ // Raw token accessor for callers that need to inject codes around their
154
+ // own text (e.g. status bar repaint loops that re-style a buffer).
155
+ paint.token = (key) => {
156
+ const t = term();
157
+ if (!t.color) return { open: '', close: '' };
158
+ return { open: openForToken(key, t.colorLevel), close: RESET };
159
+ };
160
+
161
+ return paint;
162
+ }
163
+
164
+ export const paint = buildPaint();
165
+
166
+ // ── Plain-text helper (always strips colors) ─────────────────────────────
167
+
168
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
169
+
170
+ export function strip(input) {
171
+ return String(input ?? '').replace(ANSI_RE, '');
172
+ }
173
+
174
+ /**
175
+ * Visible length of a string (ignoring ANSI codes).
176
+ * Surrogate pairs (emoji) count as 1 visual cell for layout purposes — this
177
+ * is consistent with most terminals' rendering of single-codepoint emoji.
178
+ */
179
+ export function width(input) {
180
+ const plain = strip(input);
181
+ // Strip variation selectors so "🛰️" measures as one cell.
182
+ return [...plain.replace(/︎|️/g, '')].length;
183
+ }
184
+
185
+ // ── Backwards compatibility re-exports ───────────────────────────────────
186
+ // `ansi.mjs` and other legacy modules import these names. New code should
187
+ // prefer `paint.brand.primary(...)` etc.
188
+
189
+ export const RESET_CODE = RESET;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Single shared spinner — Mission Control (PRD-055 §4.4).
3
+ *
4
+ * One spinner instance per process. The repl and status bar consume frames
5
+ * from the same source so the eye never sees two animations out of phase.
6
+ *
7
+ * Usage:
8
+ *
9
+ * const stop = startSpinner('Reading auth.py');
10
+ * await doWork();
11
+ * stop(); // clears the line and stops the timer
12
+ *
13
+ * Or, for a managed line that already exists (e.g. the status bar):
14
+ *
15
+ * const tick = spinnerFrame(); // current frame, advances on next call
16
+ *
17
+ * Behavior:
18
+ * - 120ms per frame.
19
+ * - Suppressed entirely when stdout is not a TTY or when KEPLER_PLAIN=1.
20
+ * The `start`/`stop` API is still safe to call (no-op).
21
+ * - Color follows the current orbit (orchestrated by the caller via the
22
+ * palette token). Default: brand.primary.
23
+ * - ASCII fallback (when no Unicode): 'plain dot rotation'.
24
+ */
25
+
26
+ import { paint } from './palette.mjs';
27
+ import { term } from './term.mjs';
28
+
29
+ // 8-step rotation. The PRD spec calls for ◯ → ◔ → ◑ → ◕ → ● → ◕ → ◑ → ◔
30
+ // which yields a perceptual "breathing" cycle rather than a left-right spin.
31
+ const FRAMES_UTF = ['◯', '◔', '◑', '◕', '●', '◕', '◑', '◔'];
32
+ const FRAMES_ASCII = ['.', 'o', 'O', '@', 'O', 'o', '.', ' '];
33
+
34
+ const INTERVAL_MS = 120;
35
+
36
+ let _frame = 0;
37
+
38
+ /**
39
+ * Current spinner glyph. Advances the cursor each call.
40
+ * Honors capability detection automatically.
41
+ */
42
+ export function spinnerFrame(painter = paint.brand.primary) {
43
+ const frames = term().unicode ? FRAMES_UTF : FRAMES_ASCII;
44
+ const ch = frames[_frame % frames.length];
45
+ _frame = (_frame + 1) % frames.length;
46
+ return painter ? painter(ch) : ch;
47
+ }
48
+
49
+ /**
50
+ * Reset to frame 0 — useful at the start of a new turn so consecutive
51
+ * tool calls do not inherit each other's phase.
52
+ */
53
+ export function resetSpinner() {
54
+ _frame = 0;
55
+ }
56
+
57
+ /**
58
+ * Start an inline spinner attached to `text`. Returns a stop function.
59
+ *
60
+ * The line is re-rendered in place using carriage return + erase, so the
61
+ * caller does not need to manage cursor state. If the terminal cannot
62
+ * render in place (non-TTY, dumb terminal, plain mode), the spinner becomes
63
+ * a single static line `"… text"` written once.
64
+ */
65
+ export function startSpinner(text, { stream = process.stderr, painter, color = 'brand.primary' } = {}) {
66
+ const t = term();
67
+ if (!t.isTTY || t.plain || !t.color) {
68
+ try { stream.write(`… ${text}\n`); } catch {}
69
+ return () => {};
70
+ }
71
+
72
+ const paintFn = painter || tokenPainter(color);
73
+ let stopped = false;
74
+
75
+ const render = () => {
76
+ if (stopped) return;
77
+ const glyph = spinnerFrame(paintFn);
78
+ try {
79
+ stream.write(`\r\x1b[2K${glyph} ${paint.text.dim(text)}`);
80
+ } catch {
81
+ // Stream closed mid-spin — stop quietly.
82
+ stop();
83
+ }
84
+ };
85
+
86
+ render();
87
+ const handle = setInterval(render, INTERVAL_MS);
88
+
89
+ function stop() {
90
+ if (stopped) return;
91
+ stopped = true;
92
+ clearInterval(handle);
93
+ try {
94
+ stream.write('\r\x1b[2K');
95
+ } catch {}
96
+ }
97
+
98
+ return stop;
99
+ }
100
+
101
+ /**
102
+ * Look up a painter function from a dotted token name (e.g. 'brand.accent').
103
+ * Falls back to the identity painter when the token does not exist.
104
+ */
105
+ function tokenPainter(tokenPath) {
106
+ const [ns, name] = String(tokenPath || '').split('.');
107
+ const group = paint[ns];
108
+ if (group && typeof group[name] === 'function') return group[name];
109
+ return (s) => String(s ?? '');
110
+ }
111
+
112
+ /**
113
+ * Interval used by the shared spinner. Exposed for the status bar to
114
+ * synchronize its own re-paints with the spinner phase.
115
+ */
116
+ export const SPINNER_INTERVAL_MS = INTERVAL_MS;
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Persistent two-line status bar — Mission Control (PRD-055 §5).
3
+ *
4
+ * Anchors itself to the bottom two rows of the terminal using a DECSTBM
5
+ * scroll region:
6
+ *
7
+ * ┌────────────────────────────────────────────────┐
8
+ * │ scroll region (rows 1..rows-2) │
9
+ * │ scroll region (rows 1..rows-2) │
10
+ * │ scroll region (rows 1..rows-2) │
11
+ * ├────────────────────────────────────────────────┤
12
+ * │ status line 1 — ORBIT | TASK | TURN | COST │
13
+ * │ status line 2 — keyboard dock │
14
+ * └────────────────────────────────────────────────┘
15
+ *
16
+ * The scroll region is set once at mount; afterwards normal stdout/stderr
17
+ * writes scroll within the upper region without ever touching the bar.
18
+ *
19
+ * Behavior:
20
+ * - No-op when stdout is not a TTY, or when KEPLER_PLAIN=1.
21
+ * The `mount/unmount/setOrbit` API is still safe to call.
22
+ * - Re-renders on state change (event-driven), not on a timer (except
23
+ * while in a state with a live spinner — see `_tickIfNeeded`).
24
+ * - SIGWINCH handler re-pads and re-sets the scroll region.
25
+ * - `unmount()` MUST be called before exit so the scroll region is
26
+ * restored and the cursor is shown.
27
+ *
28
+ * Implementation notes:
29
+ * - All output goes to stderr to keep stdout pipe-clean for `--print`/
30
+ * headless modes; if stdout is the only thing piped, the headless
31
+ * branch already short-circuits this module before any escape codes
32
+ * are emitted.
33
+ * - The PRD's "tput sc/rc" hint is shorthand. We use the actual VT100
34
+ * scroll region (`CSI top;bot r`) plus save/restore cursor.
35
+ */
36
+
37
+ import { paint, width as visibleWidth } from './palette.mjs';
38
+ import { icons } from './icons.mjs';
39
+ import { spinnerFrame, SPINNER_INTERVAL_MS } from './spinner.mjs';
40
+ import { term, onResize } from './term.mjs';
41
+ import { ORBITS } from '../state/orbit.mjs';
42
+ import { dockForOrbit, renderDock } from './dock.mjs';
43
+
44
+ const ESC = '\x1b[';
45
+ const OUT = process.stderr;
46
+
47
+ // ── Orbit metadata: visual style + label ─────────────────────────────────
48
+
49
+ const ORBIT_META = {
50
+ [ORBITS.IDLE]: { label: 'IDLE', paint: (s) => paint.text.dim(s), spinning: false },
51
+ [ORBITS.DISCOVERY]: { label: 'DISCOVERY', paint: (s) => paint.text.dim(s), spinning: true },
52
+ [ORBITS.PLANNING]: { label: 'PLANNING', paint: (s) => paint.state.warn(s), spinning: true },
53
+ [ORBITS.EXECUTION]: { label: 'EXECUTION', paint: (s) => paint.brand.primary(s), spinning: true },
54
+ [ORBITS.ALIGNMENT]: { label: 'ALIGNMENT', paint: (s) => paint.brand.data(s), spinning: true },
55
+ [ORBITS.AWAITING]: { label: 'AWAITING', paint: (s) => paint.brand.accent(s), spinning: false, border: true },
56
+ [ORBITS.PAUSED]: { label: 'PAUSED', paint: (s) => paint.state.warn(s), spinning: false },
57
+ };
58
+
59
+ // ── State held by the singleton ──────────────────────────────────────────
60
+
61
+ let mounted = false;
62
+ let unsubResize = null;
63
+ let tickInterval = null;
64
+ let lastSnapshot = null;
65
+ let resetting = false;
66
+
67
+ // ── Low-level cursor / region helpers ────────────────────────────────────
68
+
69
+ function write(s) { try { OUT.write(s); } catch {} }
70
+
71
+ function setScrollRegion(top, bottom) { write(`${ESC}${top};${bottom}r`); }
72
+ function clearScrollRegion() { write(`${ESC}r`); }
73
+ function saveCursor() { write(`${ESC}s`); }
74
+ function restoreCursor() { write(`${ESC}u`); }
75
+ function moveTo(row, col) { write(`${ESC}${row};${col}H`); }
76
+ function clearLine() { write(`${ESC}2K`); }
77
+ function hideCursor() { write(`${ESC}?25l`); }
78
+ function showCursor() { write(`${ESC}?25h`); }
79
+
80
+ // ── Layout: assemble the two lines ───────────────────────────────────────
81
+
82
+ function formatCost(usd) {
83
+ if (typeof usd !== 'number' || !Number.isFinite(usd)) return '$0.00';
84
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
85
+ return `$${usd.toFixed(2)}`;
86
+ }
87
+
88
+ function buildLineOne(snap, cols) {
89
+ const meta = ORBIT_META[snap.orbit] || ORBIT_META[ORBITS.IDLE];
90
+ const sep = paint.text.dim(' │ ');
91
+
92
+ const glyph = meta.spinning ? spinnerFrame(meta.paint) : meta.paint(icons.orbit);
93
+ const orbitLabel = `${glyph} ${meta.paint('ORBIT: ' + meta.label)}`;
94
+
95
+ const segments = [orbitLabel];
96
+
97
+ if (snap.task) {
98
+ segments.push(paint.text.dim('TASK: ') + paint.text.primary(snap.task));
99
+ }
100
+ if (snap.subAgents > 0) {
101
+ const noun = snap.subAgents === 1 ? 'sub-agent' : 'sub-agents';
102
+ segments.push(paint.brand.data(`${icons.subAgent} ${snap.subAgents} ${noun}`) + ' ' + paint.text.dim('active'));
103
+ }
104
+ if (snap.turn > 0) {
105
+ const turnText = snap.maxTurn > 0 ? `TURN ${snap.turn}/${snap.maxTurn}` : `TURN ${snap.turn}`;
106
+ segments.push(paint.text.dim(turnText));
107
+ }
108
+ segments.push(paint.text.dim('COST ') + paint.brand.data(formatCost(snap.cost)));
109
+
110
+ return assembleLine(segments, sep, cols);
111
+ }
112
+
113
+ function buildLineTwo(snap, cols) {
114
+ if (snap.orbit === ORBITS.AWAITING && snap.awaitingTool) {
115
+ const prefix = paint.brand.accent(`${icons.warn} APPROVAL `) + paint.text.primary(snap.awaitingTool);
116
+ const tail = renderDock(dockForOrbit(snap.orbit), Math.max(0, cols - visibleWidth(prefix) - 2));
117
+ return ` ${prefix} ${tail}`;
118
+ }
119
+ const hints = dockForOrbit(snap.orbit);
120
+ return ' ' + renderDock(hints, cols - 1);
121
+ }
122
+
123
+ /**
124
+ * Concatenate segments with separators, dropping trailing segments when
125
+ * they exceed `cols` of visible width. Always keeps the first segment.
126
+ */
127
+ function assembleLine(segments, sep, cols) {
128
+ if (segments.length === 0) return '';
129
+ let out = ' ' + segments[0];
130
+ let used = 1 + visibleWidth(segments[0]);
131
+ for (let i = 1; i < segments.length; i++) {
132
+ const piece = sep + segments[i];
133
+ const cost = visibleWidth(piece);
134
+ if (used + cost > cols) break;
135
+ out += piece;
136
+ used += cost;
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // ── Render ───────────────────────────────────────────────────────────────
142
+
143
+ function paintLine(text, cols) {
144
+ const pad = Math.max(0, cols - visibleWidth(text));
145
+ return text + ' '.repeat(pad);
146
+ }
147
+
148
+ function render(snap) {
149
+ if (!mounted || !snap) return;
150
+ const t = term();
151
+ if (!t.isTTY || t.plain) return;
152
+
153
+ const cols = Math.max(20, t.columns);
154
+ const rows = Math.max(4, t.rows);
155
+
156
+ const line1 = paintLine(buildLineOne(snap, cols), cols);
157
+ const line2 = paintLine(buildLineTwo(snap, cols), cols);
158
+
159
+ saveCursor();
160
+ moveTo(rows - 1, 1);
161
+ clearLine();
162
+ write(line1);
163
+ moveTo(rows, 1);
164
+ clearLine();
165
+ write(line2);
166
+ restoreCursor();
167
+ }
168
+
169
+ // ── Public API ───────────────────────────────────────────────────────────
170
+
171
+ /**
172
+ * Mount the status bar. Sets up the scroll region, hides the cursor, and
173
+ * registers SIGWINCH. Returns `false` when the environment is not TTY
174
+ * (caller can short-circuit).
175
+ *
176
+ * Safe to call multiple times — re-entrant calls are no-ops.
177
+ */
178
+ export function mount() {
179
+ if (mounted) return true;
180
+ const t = term();
181
+ if (!t.isTTY || t.plain) return false;
182
+
183
+ // Reserve the bottom 2 rows.
184
+ const rows = Math.max(4, t.rows);
185
+ setScrollRegion(1, rows - 2);
186
+ moveTo(rows - 1, 1); clearLine();
187
+ moveTo(rows, 1); clearLine();
188
+ // Restore cursor into the scroll region so subsequent writes go there.
189
+ moveTo(rows - 2, 1);
190
+
191
+ unsubResize = onResize(() => {
192
+ if (!mounted) return;
193
+ const r = Math.max(4, term().rows);
194
+ setScrollRegion(1, r - 2);
195
+ if (lastSnapshot) render(lastSnapshot);
196
+ });
197
+
198
+ // Cleanup hooks — exits, signals, uncaught crash all restore the terminal.
199
+ process.once('exit', safeUnmount);
200
+ process.once('SIGINT', () => { safeUnmount(); process.exit(130); });
201
+ process.once('SIGTERM', () => { safeUnmount(); process.exit(143); });
202
+
203
+ mounted = true;
204
+ return true;
205
+ }
206
+
207
+ /**
208
+ * Tear down: restore scroll region, show cursor. Must be called before
209
+ * process exit. Safe to call when not mounted.
210
+ */
211
+ export function unmount() {
212
+ if (!mounted || resetting) return;
213
+ resetting = true;
214
+ try {
215
+ clearScrollRegion();
216
+ showCursor();
217
+ if (unsubResize) { unsubResize(); unsubResize = null; }
218
+ if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
219
+ } finally {
220
+ mounted = false;
221
+ resetting = false;
222
+ }
223
+ }
224
+
225
+ function safeUnmount() { try { unmount(); } catch {} }
226
+
227
+ /**
228
+ * Push a new orbit snapshot. Triggers a render and starts/stops the
229
+ * spinner tick as needed.
230
+ */
231
+ export function setOrbit(snap) {
232
+ lastSnapshot = snap;
233
+ if (!mounted) return;
234
+ const meta = ORBIT_META[snap.orbit] || ORBIT_META[ORBITS.IDLE];
235
+ if (meta.spinning) startTick();
236
+ else stopTick();
237
+ render(snap);
238
+ }
239
+
240
+ /** Force an immediate redraw using the last known snapshot. */
241
+ export function redraw() {
242
+ if (lastSnapshot) render(lastSnapshot);
243
+ }
244
+
245
+ function startTick() {
246
+ if (tickInterval || !mounted) return;
247
+ tickInterval = setInterval(() => {
248
+ if (!mounted || !lastSnapshot) return;
249
+ render(lastSnapshot);
250
+ }, SPINNER_INTERVAL_MS);
251
+ if (typeof tickInterval.unref === 'function') tickInterval.unref();
252
+ }
253
+
254
+ function stopTick() {
255
+ if (!tickInterval) return;
256
+ clearInterval(tickInterval);
257
+ tickInterval = null;
258
+ }
259
+
260
+ /**
261
+ * Connect an `orbit` state machine instance to this status bar. Returns an
262
+ * unsubscribe function. The bar is mounted automatically; tear it down with
263
+ * `unmount()` or by calling the returned function (which also unmounts).
264
+ */
265
+ export function attachOrbit(orbit) {
266
+ if (!orbit || typeof orbit.on !== 'function') return () => {};
267
+ if (!mount()) return () => {}; // non-TTY: silently ignore
268
+ const unsub = orbit.on('change', setOrbit);
269
+ // Initial paint
270
+ setOrbit(orbit.state());
271
+ return () => {
272
+ try { unsub(); } catch {}
273
+ unmount();
274
+ };
275
+ }