@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.
- package/package.json +5 -2
- package/pulse/app/api/benchmark/route.ts +113 -0
- package/pulse/app/api/benchmarks/route.ts +195 -0
- package/pulse/app/benchmarks/page.tsx +224 -0
- package/pulse/components/layout/bottom-nav.tsx +2 -1
- package/pulse/components/layout/sidebar.tsx +2 -1
- package/src/context/retriever.mjs +42 -4
- package/src/context/symbol-indexer.mjs +375 -0
- package/src/core/approval.mjs +154 -95
- package/src/core/backend-url.mjs +2 -2
- package/src/core/headless.mjs +5 -0
- package/src/core/risk-tier.mjs +245 -0
- package/src/core/stream-client.mjs +24 -1
- package/src/core/tool-executor.mjs +58 -5
- package/src/onboarding/preflight.mjs +292 -0
- package/src/state/orbit.mjs +263 -0
- package/src/state/verbosity.mjs +99 -0
- package/src/terminal/ansi.mjs +44 -22
- package/src/terminal/repl.mjs +487 -133
- package/src/tools/project-overview.mjs +109 -16
- package/src/ui/approval.mjs +167 -0
- package/src/ui/banner.mjs +133 -122
- package/src/ui/dock.mjs +88 -0
- package/src/ui/icons.mjs +164 -0
- package/src/ui/mission-report.mjs +264 -0
- package/src/ui/palette.mjs +189 -0
- package/src/ui/spinner.mjs +116 -0
- package/src/ui/status-bar.mjs +275 -0
- package/src/ui/sub-agent.mjs +152 -0
- package/src/ui/term.mjs +159 -0
- package/src/ui/tool-card.mjs +322 -0
- package/src/ui/tool-details.mjs +277 -0
|
@@ -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
|
+
}
|