@axplusb/kepler 1.0.9 → 2.0.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.
@@ -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
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Sub-agent block renderer — Mission Control (PRD-055 §7).
3
+ *
4
+ * Renders the open/close pair for a sub-agent block, dimmed throughout so
5
+ * the primary agent reads bright by contrast. Inner tool cards are indented
6
+ * via the `subAgentIndent()` helper so they nest visually under the header.
7
+ *
8
+ * 🛰️ explore "JWT lifecycle" ▸ running (deepseek/deepseek-v4-flash)
9
+ * 🔭 Search code "expire" → 6 matches
10
+ * 🔭 Read file auth.py L120-180 → 60 lines
11
+ * └ ✅ returned 3 files identified · $0.004 · 2.1s
12
+ *
13
+ * Maintains a depth stack so concurrent / nested sub-agents indent further
14
+ * and so callers can ask `inSubAgent()` / `depth()` without threading state.
15
+ *
16
+ * No I/O — caller writes the returned strings to stderr. This keeps the
17
+ * module testable from a plain Node script.
18
+ */
19
+
20
+ import { paint } from './palette.mjs';
21
+ import { icons } from './icons.mjs';
22
+
23
+ const SUB_ICONS = {
24
+ explore: '🔭',
25
+ plan: '📐',
26
+ verify: '✅',
27
+ debug: '🪲',
28
+ refactor:'♻️',
29
+ };
30
+
31
+ // ── Active stack ─────────────────────────────────────────────────────────
32
+
33
+ const _stack = []; // [{ id, type, startedAt }]
34
+
35
+ /** How many sub-agents are currently open. */
36
+ export function depth() { return _stack.length; }
37
+ export function inSubAgent() { return _stack.length > 0; }
38
+
39
+ /**
40
+ * Indent string for a tool card line nested under N sub-agents.
41
+ * 5 cols per level matches the existing `' '` legacy indent.
42
+ */
43
+ export function subAgentIndent(extraDepth = 0) {
44
+ const d = _stack.length + extraDepth;
45
+ if (d <= 0) return ' ';
46
+ return ' '.repeat(2 + d * 3);
47
+ }
48
+
49
+ // ── Render ───────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Open a sub-agent block. Pushes onto the stack; returns the lines to print.
53
+ *
54
+ * @returns {string} ANSI-styled multi-line block (no trailing newline).
55
+ */
56
+ export function renderSubAgentOpen({ id, type, model, query, parentDepth } = {}) {
57
+ const t = type || 'sub-agent';
58
+ const depthBefore = _stack.length;
59
+ _stack.push({ id: id || `${t}-${depthBefore}-${tag()}`, type: t, startedAt: Date.now() });
60
+
61
+ const indent = ' '.repeat(2 + depthBefore * 3);
62
+ const iconChar = SUB_ICONS[t] || icons.subAgent;
63
+ const head = `${indent}${iconChar} ${paint.brand.data(t)} ${paint.text.dim(`"${truncate(query || '', 60)}"`)}`;
64
+ const tag1 = paint.text.dim(`▸ running${model ? ` (${model})` : ''}`);
65
+
66
+ return query
67
+ ? `\n${head} ${tag1}`
68
+ : `\n${indent}${iconChar} ${paint.brand.data(t)} ${tag1}`;
69
+ }
70
+
71
+ /**
72
+ * Close the most recent sub-agent block. Pops the stack; returns the close
73
+ * line with optional cost / token / duration attribution per PRD §7.3.
74
+ *
75
+ * └ ✅ returned 3 files identified · 1.2k tok · $0.004 · 2.1s
76
+ * └ ✗ explore agent failed
77
+ *
78
+ * Caller passes `success` (default true), `summary` ("returned N files"),
79
+ * and any of `{ costUsd, tokens, durationS, toolCalls, iterations }`.
80
+ */
81
+ export function renderSubAgentClose({
82
+ type,
83
+ success = true,
84
+ summary = '',
85
+ costUsd,
86
+ tokens,
87
+ durationS,
88
+ toolCalls,
89
+ iterations,
90
+ error,
91
+ } = {}) {
92
+ // Match-pop: if the type doesn't match the top of stack we still pop the
93
+ // top entry — backends never emit interleaved open/close, so this is the
94
+ // safe behavior.
95
+ const opened = _stack.pop();
96
+ const t = type || opened?.type || 'sub-agent';
97
+ const indent = ' '.repeat(2 + _stack.length * 3);
98
+
99
+ if (!success) {
100
+ const line = `${indent}${paint.text.dim('└')} ${paint.state.danger('✗')} ${paint.text.dim(`${t} agent failed`)}`;
101
+ if (error) {
102
+ return `${line}\n${indent} ${paint.state.danger(truncate(error, 140))}`;
103
+ }
104
+ return line;
105
+ }
106
+
107
+ const parts = [];
108
+ if (toolCalls > 0) parts.push(`${toolCalls} tools`);
109
+ if (iterations > 0) parts.push(`${iterations} iter`);
110
+ if (tokens > 0) parts.push(`${formatTokens(tokens)} tok`);
111
+ if (typeof costUsd === 'number' && costUsd > 0) parts.push(formatCost(costUsd));
112
+ if (durationS != null) parts.push(`${Number(durationS).toFixed(1)}s`);
113
+ const detail = parts.length ? paint.text.dim(' · ' + parts.join(' · ')) : '';
114
+
115
+ const body = summary
116
+ ? paint.text.dim(summary)
117
+ : paint.text.dim(`${t} returned`);
118
+
119
+ return `${indent}${paint.text.dim('└')} ${paint.state.success('✅')} ${body}${detail}`;
120
+ }
121
+
122
+ /**
123
+ * Force-clear the stack. Use after a `complete` event or when cancelling so
124
+ * a stale entry doesn't keep indenting future output.
125
+ */
126
+ export function resetSubAgents() { _stack.length = 0; }
127
+
128
+ // ── helpers ──────────────────────────────────────────────────────────────
129
+
130
+ function truncate(text, n) {
131
+ const s = String(text || '');
132
+ return s.length <= n ? s : s.slice(0, n - 1) + '…';
133
+ }
134
+
135
+ function formatTokens(n) {
136
+ if (!Number.isFinite(n)) return '0';
137
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
138
+ return String(Math.round(n));
139
+ }
140
+
141
+ function formatCost(usd) {
142
+ if (usd < 0.001) return `$${usd.toFixed(5)}`;
143
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
144
+ return `$${usd.toFixed(3)}`;
145
+ }
146
+
147
+ function tag() {
148
+ // Avoid Date.now()/Math.random() drift across re-renders — depth+counter is
149
+ // enough to keep ids unique within a process.
150
+ tag._n = (tag._n || 0) + 1;
151
+ return tag._n.toString(36);
152
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Terminal capability detection.
3
+ *
4
+ * Resolves once at import. Re-evaluation requires `refresh()` (used by tests
5
+ * and the rare command that toggles a relevant env var mid-process).
6
+ *
7
+ * Capability tiers (highest first):
8
+ * truecolor - 24-bit RGB (e.g. iTerm2, modern xterm, Windows Terminal)
9
+ * ansi256 - 256-color palette
10
+ * ansi16 - basic 16 colors
11
+ * none - no color (NO_COLOR=1, dumb terminal, non-TTY without override)
12
+ */
13
+
14
+ const TRUECOLOR_TERMS = new Set([
15
+ 'truecolor',
16
+ '24bit',
17
+ '24-bit',
18
+ ]);
19
+
20
+ const ANSI256_TERMS = [
21
+ /-256(color)?$/i,
22
+ /^xterm/i,
23
+ /^screen/i,
24
+ /^tmux/i,
25
+ /^rxvt-unicode/i,
26
+ /^alacritty/i,
27
+ ];
28
+
29
+ const DUMB_TERMS = new Set(['', 'dumb', 'unknown']);
30
+
31
+ function readEnv() {
32
+ const env = process.env || {};
33
+ return {
34
+ NO_COLOR: env.NO_COLOR,
35
+ FORCE_COLOR: env.FORCE_COLOR,
36
+ KEPLER_PLAIN: env.KEPLER_PLAIN,
37
+ COLORTERM: (env.COLORTERM || '').toLowerCase(),
38
+ TERM: (env.TERM || '').toLowerCase(),
39
+ TERM_PROGRAM: env.TERM_PROGRAM || '',
40
+ CI: env.CI,
41
+ };
42
+ }
43
+
44
+ function detectColorLevel(env, isTTY) {
45
+ // Hard opt-out (https://no-color.org). Honored even on TTYs.
46
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return 'none';
47
+ if (env.KEPLER_PLAIN === '1') return 'none';
48
+
49
+ // Hard opt-in. FORCE_COLOR=1|2|3 maps to ansi16|ansi256|truecolor.
50
+ // FORCE_COLOR with no value or =true falls through to detection.
51
+ if (env.FORCE_COLOR !== undefined) {
52
+ const v = String(env.FORCE_COLOR).trim();
53
+ if (v === '0' || v === 'false') return 'none';
54
+ if (v === '1') return 'ansi16';
55
+ if (v === '2') return 'ansi256';
56
+ if (v === '3') return 'truecolor';
57
+ // Any other truthy value: continue detection but allow non-TTY.
58
+ isTTY = true;
59
+ }
60
+
61
+ // No TTY and not forced: no color.
62
+ if (!isTTY) return 'none';
63
+
64
+ if (DUMB_TERMS.has(env.TERM)) return 'none';
65
+
66
+ if (TRUECOLOR_TERMS.has(env.COLORTERM)) return 'truecolor';
67
+
68
+ // Some terminal emulators advertise truecolor through TERM_PROGRAM.
69
+ if (env.TERM_PROGRAM === 'iTerm.app' || env.TERM_PROGRAM === 'WezTerm') {
70
+ return 'truecolor';
71
+ }
72
+ if (env.TERM_PROGRAM === 'vscode' || env.TERM_PROGRAM === 'Apple_Terminal') {
73
+ return 'ansi256';
74
+ }
75
+
76
+ if (ANSI256_TERMS.some(re => re.test(env.TERM))) return 'ansi256';
77
+
78
+ return 'ansi16';
79
+ }
80
+
81
+ function detectUnicode(env) {
82
+ if (env.KEPLER_PLAIN === '1') return false;
83
+ // Most modern terminals on macOS/Linux handle UTF-8.
84
+ // Windows ConEmu / older terminals are the main holdouts; conservative
85
+ // fallback when LANG and LC_* are missing or explicitly POSIX.
86
+ const lang = (process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || '').toLowerCase();
87
+ if (!lang) return process.platform !== 'win32';
88
+ if (lang.includes('utf')) return true;
89
+ return false;
90
+ }
91
+
92
+ function compute() {
93
+ const env = readEnv();
94
+ const isTTY = !!(process.stdout && process.stdout.isTTY);
95
+ const level = detectColorLevel(env, isTTY);
96
+ return {
97
+ isTTY,
98
+ colorLevel: level, // 'none' | 'ansi16' | 'ansi256' | 'truecolor'
99
+ color: level !== 'none',
100
+ truecolor: level === 'truecolor',
101
+ ansi256: level === 'ansi256' || level === 'truecolor',
102
+ unicode: detectUnicode(env),
103
+ plain: env.KEPLER_PLAIN === '1',
104
+ columns: (process.stdout && process.stdout.columns) || 80,
105
+ rows: (process.stdout && process.stdout.rows) || 24,
106
+ ci: !!env.CI,
107
+ };
108
+ }
109
+
110
+ let _capabilities = compute();
111
+
112
+ /**
113
+ * Current terminal capabilities. Stable until `refresh()` is called.
114
+ */
115
+ export function term() {
116
+ return _capabilities;
117
+ }
118
+
119
+ /**
120
+ * Re-run capability detection. Tests, `/config` reloads, or runtime env changes.
121
+ */
122
+ export function refresh() {
123
+ _capabilities = compute();
124
+ return _capabilities;
125
+ }
126
+
127
+ /**
128
+ * Listen for terminal resizes. Returns an unsubscribe function.
129
+ * Callers receive the latest capabilities object (with updated columns/rows).
130
+ */
131
+ export function onResize(handler) {
132
+ if (typeof handler !== 'function') return () => {};
133
+ const stream = process.stdout;
134
+ if (!stream || typeof stream.on !== 'function') return () => {};
135
+
136
+ const onChange = () => {
137
+ _capabilities = {
138
+ ..._capabilities,
139
+ columns: stream.columns || _capabilities.columns,
140
+ rows: stream.rows || _capabilities.rows,
141
+ };
142
+ handler(_capabilities);
143
+ };
144
+ stream.on('resize', onChange);
145
+ return () => stream.off('resize', onChange);
146
+ }
147
+
148
+ /**
149
+ * Force a capability level. Test-only escape hatch.
150
+ * Pass `null` to clear and re-detect.
151
+ */
152
+ export function _setForTesting(overrides) {
153
+ if (overrides === null) {
154
+ _capabilities = compute();
155
+ return _capabilities;
156
+ }
157
+ _capabilities = { ..._capabilities, ...overrides };
158
+ return _capabilities;
159
+ }