@axplusb/kepler 1.0.10 → 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.
- package/package.json +5 -2
- 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 +239 -0
- package/src/core/tool-executor.mjs +49 -3
- package/src/onboarding/preflight.mjs +274 -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 +395 -108
- 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 +314 -0
- package/src/ui/tool-details.mjs +277 -0
package/src/ui/icons.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand icon registry — Mission Control (PRD-055 §4.2).
|
|
3
|
+
*
|
|
4
|
+
* Every CLI surface that prints a tool, phase, or status uses these icons.
|
|
5
|
+
* Each icon has a Unicode form and an ASCII fallback used when the terminal
|
|
6
|
+
* advertises no Unicode support (older Windows shells, locked-down CI).
|
|
7
|
+
*
|
|
8
|
+
* import { icons, icon } from './icons.mjs';
|
|
9
|
+
* process.stdout.write(`${icons.subAgent} explore ${icons.pass} pass`);
|
|
10
|
+
*
|
|
11
|
+
* Or by token (for data-driven tool display):
|
|
12
|
+
*
|
|
13
|
+
* icon('shell') // ⚙️
|
|
14
|
+
* icon('edit_file') // 🛠️
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { term } from './term.mjs';
|
|
18
|
+
|
|
19
|
+
// ── Canonical icons ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
// Note: cannot Object.freeze() this — it is the target of a Proxy whose
|
|
22
|
+
// `get` returns a rendered string instead of the raw record, which violates
|
|
23
|
+
// the Proxy invariant for frozen targets.
|
|
24
|
+
const ICONS = ({
|
|
25
|
+
// Brand
|
|
26
|
+
brand: { utf: '✦', ascii: '*' },
|
|
27
|
+
orbit: { utf: '◯', ascii: 'O' },
|
|
28
|
+
|
|
29
|
+
// Tool families
|
|
30
|
+
subAgent: { utf: '🛰️', ascii: '~>' }, // explore, plan, verify, debug, refactor
|
|
31
|
+
search: { utf: '🔭', ascii: '?' }, // search_code, grep, read_file, list_files
|
|
32
|
+
write: { utf: '🛠️', ascii: '+' }, // write_file, edit_file, write_project
|
|
33
|
+
shell: { utf: '⚙️', ascii: '$' }, // shell, run_tests, validators
|
|
34
|
+
network: { utf: '🌐', ascii: '@' }, // WebFetch, MCP network calls
|
|
35
|
+
|
|
36
|
+
// State
|
|
37
|
+
pass: { utf: '✅', ascii: 'OK' },
|
|
38
|
+
warn: { utf: '⚠️', ascii: '!' },
|
|
39
|
+
fail: { utf: '❌', ascii: 'X' },
|
|
40
|
+
pending: { utf: '◔', ascii: '.' },
|
|
41
|
+
|
|
42
|
+
// Workflow
|
|
43
|
+
approve: { utf: '✔', ascii: 'Y' },
|
|
44
|
+
reject: { utf: '✘', ascii: 'N' },
|
|
45
|
+
pause: { utf: '⏸', ascii: '||' },
|
|
46
|
+
resume: { utf: '▶', ascii: '>' },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Internal table that the lookup functions consult directly. This stays
|
|
50
|
+
// frozen because we never expose it through a Proxy.
|
|
51
|
+
const ICON_RECORDS = Object.freeze({ ...ICONS });
|
|
52
|
+
|
|
53
|
+
// ── Tool → icon mapping ──────────────────────────────────────────────────
|
|
54
|
+
// One source of truth so tool display, status bar, and mission report
|
|
55
|
+
// agree. Unknown tools fall through to a generic "tool" icon.
|
|
56
|
+
|
|
57
|
+
const TOOL_ICON = Object.freeze({
|
|
58
|
+
// Sub-agents
|
|
59
|
+
explore: 'subAgent',
|
|
60
|
+
plan: 'subAgent',
|
|
61
|
+
verify: 'subAgent',
|
|
62
|
+
debug: 'subAgent',
|
|
63
|
+
refactor: 'subAgent',
|
|
64
|
+
|
|
65
|
+
// Read / search
|
|
66
|
+
read_file: 'search',
|
|
67
|
+
read_files: 'search',
|
|
68
|
+
search_code: 'search',
|
|
69
|
+
search_files: 'search',
|
|
70
|
+
grep: 'search',
|
|
71
|
+
list_files: 'search',
|
|
72
|
+
analyze_code: 'search',
|
|
73
|
+
get_file_info: 'search',
|
|
74
|
+
git_diff: 'search',
|
|
75
|
+
git_status: 'search',
|
|
76
|
+
get_project_overview: 'search',
|
|
77
|
+
|
|
78
|
+
// Write / edit
|
|
79
|
+
write_file: 'write',
|
|
80
|
+
write_project: 'write',
|
|
81
|
+
edit_file: 'write',
|
|
82
|
+
delete_file: 'write',
|
|
83
|
+
|
|
84
|
+
// Shell / validate
|
|
85
|
+
shell: 'shell',
|
|
86
|
+
run_tests: 'shell',
|
|
87
|
+
validate_build: 'shell',
|
|
88
|
+
validate_file: 'shell',
|
|
89
|
+
validate_structure:'shell',
|
|
90
|
+
lint_check: 'shell',
|
|
91
|
+
|
|
92
|
+
// Network
|
|
93
|
+
WebFetch: 'network',
|
|
94
|
+
fetch_url: 'network',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function render(spec) {
|
|
100
|
+
if (!spec) return '';
|
|
101
|
+
return term().unicode ? spec.utf : spec.ascii;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve an icon by name (e.g. `icons.subAgent`). Always safe — returns
|
|
106
|
+
* the ASCII fallback when the terminal cannot render Unicode.
|
|
107
|
+
*
|
|
108
|
+
* Implemented as a Proxy over a plain object (intentionally not frozen, see
|
|
109
|
+
* the comment above ICONS) so callers can write `icons.pass` directly.
|
|
110
|
+
*/
|
|
111
|
+
export const icons = new Proxy({}, {
|
|
112
|
+
get(_target, prop) {
|
|
113
|
+
if (typeof prop !== 'string') return undefined;
|
|
114
|
+
return render(ICON_RECORDS[prop]);
|
|
115
|
+
},
|
|
116
|
+
has(_target, prop) {
|
|
117
|
+
return typeof prop === 'string' && prop in ICON_RECORDS;
|
|
118
|
+
},
|
|
119
|
+
ownKeys() {
|
|
120
|
+
return Object.keys(ICON_RECORDS);
|
|
121
|
+
},
|
|
122
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
123
|
+
if (typeof prop !== 'string' || !(prop in ICON_RECORDS)) return undefined;
|
|
124
|
+
return { configurable: true, enumerable: true, value: render(ICON_RECORDS[prop]) };
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve the icon for a tool name. Falls back to a generic tool icon
|
|
130
|
+
* (`◇`) when the tool is unknown to the registry — this is intentional so
|
|
131
|
+
* unmapped MCP tools and user-defined tools still render with the same
|
|
132
|
+
* visual rhythm.
|
|
133
|
+
*/
|
|
134
|
+
export function icon(toolName) {
|
|
135
|
+
if (!toolName) return '';
|
|
136
|
+
const key = TOOL_ICON[toolName];
|
|
137
|
+
if (key) return render(ICON_RECORDS[key]);
|
|
138
|
+
|
|
139
|
+
// MCP tools often arrive as "mcp__server__tool" — strip the prefix and
|
|
140
|
+
// try again before falling back to the generic glyph.
|
|
141
|
+
if (toolName.startsWith('mcp')) {
|
|
142
|
+
const cleaned = toolName.replace(/^mcp[_-]+/, '').split(/[_-]+/)[0];
|
|
143
|
+
const fallback = TOOL_ICON[cleaned];
|
|
144
|
+
if (fallback) return render(ICON_RECORDS[fallback]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return term().unicode ? '◇' : '*';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Tool family (one of: subAgent, search, write, shell, network, other).
|
|
152
|
+
* Used by tier classification and color choice in the tool card renderer.
|
|
153
|
+
*/
|
|
154
|
+
export function toolFamily(toolName) {
|
|
155
|
+
return TOOL_ICON[toolName] || 'other';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Lower-level lookup for callers that want to know whether a name is in
|
|
160
|
+
* the registry (e.g. risk classification fall-throughs).
|
|
161
|
+
*/
|
|
162
|
+
export function hasIcon(name) {
|
|
163
|
+
return name in ICON_RECORDS;
|
|
164
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mission report — Mission Control (PRD-055 §11).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the trailing "Done" message at the end of a session with a
|
|
5
|
+
* structured summary:
|
|
6
|
+
*
|
|
7
|
+
* ───────────────────────────────────────────────────
|
|
8
|
+
* ✅ MISSION ACCOMPLISHED · Fix JWT expiration bug
|
|
9
|
+
* ───────────────────────────────────────────────────
|
|
10
|
+
* 📂 Files auth.py, tests/test_auth.py
|
|
11
|
+
* 🛠️ Tools read(4) edit(2) shell(1) test(1)
|
|
12
|
+
* 🛰️ Sub-agents explore(1) plan(1) · saved ≈ $0.08
|
|
13
|
+
* 💰 Cost $0.14 ⏱ Time 2m 18s
|
|
14
|
+
* ✅ Health 24/24 tests pass
|
|
15
|
+
* ───────────────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* Next: /commit /pr /undo /report
|
|
18
|
+
*
|
|
19
|
+
* Failure variant (PRD §11.1) uses ❌ MISSION HELD and lists blockers.
|
|
20
|
+
*
|
|
21
|
+
* `renderMissionReport(state)` returns the ANSI block; `toMarkdown(state)`
|
|
22
|
+
* returns the plain-markdown version saved by `/report`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import { paint, width as visibleWidth } from './palette.mjs';
|
|
28
|
+
import { icons } from './icons.mjs';
|
|
29
|
+
import { toolFamily } from './icons.mjs';
|
|
30
|
+
|
|
31
|
+
const WIDTH = 56;
|
|
32
|
+
|
|
33
|
+
// ── Public API ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Render the ANSI mission-report block.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} state
|
|
39
|
+
* task — string (the user's prompt for this session)
|
|
40
|
+
* success — boolean (overall outcome)
|
|
41
|
+
* filesChanged — string[]
|
|
42
|
+
* toolCounts — { [tool]: count } or array of {tool}
|
|
43
|
+
* subAgents — array of { type, costUsd?, tokens? } or { explore:1, plan:1 }
|
|
44
|
+
* costUsd — number
|
|
45
|
+
* durationS — number
|
|
46
|
+
* testsPass — { passed: number, total: number } | null
|
|
47
|
+
* blockers — string[] (for failure variant)
|
|
48
|
+
* nextActions — string[] (slash-command hints)
|
|
49
|
+
*/
|
|
50
|
+
export function renderMissionReport(state) {
|
|
51
|
+
const success = state.success !== false;
|
|
52
|
+
const lines = [];
|
|
53
|
+
const rule = paint.text.dim('─'.repeat(WIDTH));
|
|
54
|
+
|
|
55
|
+
const titleIcon = success ? paint.state.success('✅') : paint.state.danger('❌');
|
|
56
|
+
const titleText = success ? 'MISSION ACCOMPLISHED' : 'MISSION HELD';
|
|
57
|
+
const titleAccent = success ? paint.state.success : paint.state.danger;
|
|
58
|
+
const headerTask = state.task ? paint.text.dim(' · ') + paint.text.primary(truncate(state.task, 60)) : '';
|
|
59
|
+
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push(rule);
|
|
62
|
+
lines.push(`${titleIcon} ${paint.bold(titleAccent(titleText))}${headerTask}`);
|
|
63
|
+
lines.push(rule);
|
|
64
|
+
|
|
65
|
+
if (Array.isArray(state.filesChanged) && state.filesChanged.length) {
|
|
66
|
+
lines.push(row('📂', 'Files', formatFiles(state.filesChanged)));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const toolSummary = formatToolCounts(state.toolCounts);
|
|
70
|
+
if (toolSummary) {
|
|
71
|
+
lines.push(row(icons.write, 'Tools', toolSummary));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (state.subAgents) {
|
|
75
|
+
const subSummary = formatSubAgents(state.subAgents);
|
|
76
|
+
if (subSummary) lines.push(row(icons.subAgent, 'Sub-agents', subSummary));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Cost + time on one row.
|
|
80
|
+
const cost = state.costUsd != null ? paint.brand.data(formatCost(state.costUsd)) : '';
|
|
81
|
+
const time = state.durationS != null ? paint.brand.data(formatDuration(state.durationS)) : '';
|
|
82
|
+
if (cost || time) {
|
|
83
|
+
const segments = [];
|
|
84
|
+
if (cost) segments.push(`${paint.text.dim('💰 Cost')} ${cost}`);
|
|
85
|
+
if (time) segments.push(`${paint.text.dim('⏱ Time')} ${time}`);
|
|
86
|
+
lines.push(' ' + segments.join(' '));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Test health.
|
|
90
|
+
if (state.testsPass && typeof state.testsPass.total === 'number' && state.testsPass.total > 0) {
|
|
91
|
+
const { passed = 0, total = 0 } = state.testsPass;
|
|
92
|
+
const allGreen = passed === total;
|
|
93
|
+
const icon = allGreen ? paint.state.success('✅') : paint.state.danger('❌');
|
|
94
|
+
const text = allGreen
|
|
95
|
+
? `${passed}/${total} tests pass`
|
|
96
|
+
: `${passed}/${total} tests pass · ${paint.state.danger((total - passed) + ' failing')}`;
|
|
97
|
+
lines.push(row(icon, allGreen ? 'Health' : 'Tests', text, /*alreadyIcon*/ true));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lines.push(rule);
|
|
101
|
+
|
|
102
|
+
if (!success && Array.isArray(state.blockers) && state.blockers.length) {
|
|
103
|
+
lines.push('');
|
|
104
|
+
lines.push(' ' + paint.bold(paint.state.danger('Blocked by:')));
|
|
105
|
+
for (const b of state.blockers.slice(0, 6)) {
|
|
106
|
+
lines.push(' ' + paint.text.dim('•') + ' ' + paint.text.primary(truncate(b, WIDTH * 2)));
|
|
107
|
+
}
|
|
108
|
+
if (state.blockers.length > 6) {
|
|
109
|
+
lines.push(' ' + paint.text.dim(`… ${state.blockers.length - 6} more`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Array.isArray(state.nextActions) && state.nextActions.length) {
|
|
114
|
+
lines.push('');
|
|
115
|
+
const next = state.nextActions.map(a => paint.brand.data(a)).join(paint.text.dim(' '));
|
|
116
|
+
lines.push(' ' + paint.text.dim('Next: ') + next);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push('');
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Same content as renderMissionReport, but as plain markdown so callers
|
|
125
|
+
* can persist it under `.kepler/reports/`.
|
|
126
|
+
*/
|
|
127
|
+
export function toMarkdown(state) {
|
|
128
|
+
const success = state.success !== false;
|
|
129
|
+
const out = [];
|
|
130
|
+
out.push(`# ${success ? '✅ Mission Accomplished' : '❌ Mission Held'}${state.task ? ' — ' + state.task : ''}`);
|
|
131
|
+
out.push('');
|
|
132
|
+
if (Array.isArray(state.filesChanged) && state.filesChanged.length) {
|
|
133
|
+
out.push('**Files**: ' + state.filesChanged.join(', '));
|
|
134
|
+
}
|
|
135
|
+
const toolSummary = stripAnsi(formatToolCounts(state.toolCounts) || '');
|
|
136
|
+
if (toolSummary) out.push('**Tools**: ' + toolSummary);
|
|
137
|
+
if (state.subAgents) {
|
|
138
|
+
const sub = stripAnsi(formatSubAgents(state.subAgents) || '');
|
|
139
|
+
if (sub) out.push('**Sub-agents**: ' + sub);
|
|
140
|
+
}
|
|
141
|
+
if (state.costUsd != null) out.push('**Cost**: ' + stripAnsi(formatCost(state.costUsd)));
|
|
142
|
+
if (state.durationS != null) out.push('**Time**: ' + formatDuration(state.durationS));
|
|
143
|
+
if (state.testsPass) {
|
|
144
|
+
const { passed = 0, total = 0 } = state.testsPass;
|
|
145
|
+
out.push(`**Tests**: ${passed}/${total} ${passed === total ? 'pass' : 'pass · ' + (total - passed) + ' failing'}`);
|
|
146
|
+
}
|
|
147
|
+
if (!success && Array.isArray(state.blockers) && state.blockers.length) {
|
|
148
|
+
out.push('');
|
|
149
|
+
out.push('## Blocked by');
|
|
150
|
+
for (const b of state.blockers) out.push('- ' + b);
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(state.nextActions) && state.nextActions.length) {
|
|
153
|
+
out.push('');
|
|
154
|
+
out.push('**Next**: ' + state.nextActions.join(' '));
|
|
155
|
+
}
|
|
156
|
+
out.push('');
|
|
157
|
+
return out.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Save a markdown copy of the report to `.kepler/reports/<timestamp>.md`
|
|
162
|
+
* inside the working directory. Returns the absolute path.
|
|
163
|
+
*/
|
|
164
|
+
export function saveReport(state, { cwd = process.cwd(), timestamp } = {}) {
|
|
165
|
+
const dir = path.join(cwd, '.kepler', 'reports');
|
|
166
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
167
|
+
const stamp = timestamp || new Date().toISOString().replace(/[:.]/g, '-');
|
|
168
|
+
const out = path.join(dir, `${stamp}.md`);
|
|
169
|
+
fs.writeFileSync(out, toMarkdown(state));
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function row(icon, label, value, alreadyIcon = false) {
|
|
176
|
+
const i = alreadyIcon ? icon : icon;
|
|
177
|
+
const labelText = paint.text.dim(label.padEnd(11));
|
|
178
|
+
return ` ${i} ${labelText} ${value}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function formatFiles(files) {
|
|
182
|
+
const shortened = files.map(f => paint.text.primary(path.basename(f)));
|
|
183
|
+
if (shortened.length <= 4) return shortened.join(paint.text.dim(', '));
|
|
184
|
+
return shortened.slice(0, 4).join(paint.text.dim(', ')) + paint.text.dim(`, +${files.length - 4} more`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Render `read(4) edit(2) shell(1) test(1)` from a counts object/array.
|
|
189
|
+
* Buckets by tool family so the line stays compact.
|
|
190
|
+
*/
|
|
191
|
+
function formatToolCounts(counts) {
|
|
192
|
+
if (!counts) return '';
|
|
193
|
+
const entries = Array.isArray(counts)
|
|
194
|
+
? counts
|
|
195
|
+
: Object.entries(counts).map(([tool, n]) => ({ tool, count: n }));
|
|
196
|
+
if (!entries.length) return '';
|
|
197
|
+
|
|
198
|
+
const buckets = { read: 0, edit: 0, shell: 0, test: 0, other: 0 };
|
|
199
|
+
for (const { tool, count } of entries) {
|
|
200
|
+
const c = Number(count) || 0;
|
|
201
|
+
if (!c) continue;
|
|
202
|
+
const fam = toolFamily(tool);
|
|
203
|
+
if (tool === 'run_tests' || tool === 'validate_build') buckets.test += c;
|
|
204
|
+
else if (fam === 'write') buckets.edit += c;
|
|
205
|
+
else if (fam === 'shell') buckets.shell += c;
|
|
206
|
+
else if (fam === 'search') buckets.read += c;
|
|
207
|
+
else buckets.other += c;
|
|
208
|
+
}
|
|
209
|
+
const parts = [];
|
|
210
|
+
if (buckets.read) parts.push(`${paint.brand.data('read')}(${buckets.read})`);
|
|
211
|
+
if (buckets.edit) parts.push(`${paint.brand.primary('edit')}(${buckets.edit})`);
|
|
212
|
+
if (buckets.shell) parts.push(`${paint.state.warn('shell')}(${buckets.shell})`);
|
|
213
|
+
if (buckets.test) parts.push(`${paint.state.success('test')}(${buckets.test})`);
|
|
214
|
+
if (buckets.other) parts.push(`${paint.text.muted('tool')}(${buckets.other})`);
|
|
215
|
+
return parts.join(paint.text.dim(' '));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function formatSubAgents(subAgents) {
|
|
219
|
+
// Accepts either a flat counts object { explore: 1, plan: 1, savedUsd: 0.08 }
|
|
220
|
+
// or an array of { type, costUsd, tokens }.
|
|
221
|
+
if (!subAgents) return '';
|
|
222
|
+
let counts = {};
|
|
223
|
+
let savedUsd = 0;
|
|
224
|
+
if (Array.isArray(subAgents)) {
|
|
225
|
+
for (const s of subAgents) {
|
|
226
|
+
counts[s.type] = (counts[s.type] || 0) + 1;
|
|
227
|
+
if (typeof s.savedUsd === 'number') savedUsd += s.savedUsd;
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
counts = { ...subAgents };
|
|
231
|
+
savedUsd = subAgents.savedUsd || 0;
|
|
232
|
+
delete counts.savedUsd;
|
|
233
|
+
}
|
|
234
|
+
const entries = Object.entries(counts).filter(([, n]) => Number(n) > 0);
|
|
235
|
+
if (!entries.length) return '';
|
|
236
|
+
const list = entries.map(([type, n]) => `${paint.brand.data(type)}(${n})`).join(paint.text.dim(' '));
|
|
237
|
+
if (savedUsd > 0) {
|
|
238
|
+
return list + paint.text.dim(` · saved ≈ ${formatCost(savedUsd)}`);
|
|
239
|
+
}
|
|
240
|
+
return list;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function formatCost(usd) {
|
|
244
|
+
if (typeof usd !== 'number' || !Number.isFinite(usd)) return '$0.00';
|
|
245
|
+
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
|
246
|
+
return `$${usd.toFixed(2)}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function formatDuration(s) {
|
|
250
|
+
if (typeof s !== 'number' || !Number.isFinite(s)) return '0s';
|
|
251
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
252
|
+
const m = Math.floor(s / 60);
|
|
253
|
+
const rem = Math.round(s - m * 60);
|
|
254
|
+
return `${m}m ${rem}s`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function truncate(s, n) {
|
|
258
|
+
const str = String(s || '');
|
|
259
|
+
return str.length <= n ? str : str.slice(0, n - 1) + '…';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function stripAnsi(s) {
|
|
263
|
+
return String(s || '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
264
|
+
}
|
|
@@ -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;
|