@artemiskit/cli 0.1.4 → 0.1.6
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/CHANGELOG.md +24 -0
- package/README.md +1 -0
- package/dist/index.js +19129 -20009
- package/dist/src/commands/compare.d.ts.map +1 -1
- package/dist/src/commands/history.d.ts.map +1 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/redteam.d.ts.map +1 -1
- package/dist/src/commands/report.d.ts.map +1 -1
- package/dist/src/commands/run.d.ts.map +1 -1
- package/dist/src/commands/stress.d.ts.map +1 -1
- package/dist/src/ui/colors.d.ts +44 -0
- package/dist/src/ui/colors.d.ts.map +1 -0
- package/dist/src/ui/errors.d.ts +39 -0
- package/dist/src/ui/errors.d.ts.map +1 -0
- package/dist/src/ui/index.d.ts +16 -0
- package/dist/src/ui/index.d.ts.map +1 -0
- package/dist/src/ui/live-status.d.ts +82 -0
- package/dist/src/ui/live-status.d.ts.map +1 -0
- package/dist/src/ui/panels.d.ts +49 -0
- package/dist/src/ui/panels.d.ts.map +1 -0
- package/dist/src/ui/progress.d.ts +60 -0
- package/dist/src/ui/progress.d.ts.map +1 -0
- package/dist/src/ui/utils.d.ts +42 -0
- package/dist/src/ui/utils.d.ts.map +1 -0
- package/package.json +6 -6
- package/src/__tests__/helpers/index.ts +6 -0
- package/src/__tests__/helpers/mock-adapter.ts +90 -0
- package/src/__tests__/helpers/test-utils.ts +205 -0
- package/src/__tests__/integration/compare-command.test.ts +236 -0
- package/src/__tests__/integration/config.test.ts +125 -0
- package/src/__tests__/integration/history-command.test.ts +251 -0
- package/src/__tests__/integration/init-command.test.ts +177 -0
- package/src/__tests__/integration/report-command.test.ts +245 -0
- package/src/__tests__/integration/ui.test.ts +230 -0
- package/src/commands/compare.ts +158 -49
- package/src/commands/history.ts +131 -30
- package/src/commands/init.ts +181 -21
- package/src/commands/redteam.ts +118 -75
- package/src/commands/report.ts +29 -14
- package/src/commands/run.ts +86 -66
- package/src/commands/stress.ts +61 -63
- package/src/ui/colors.ts +62 -0
- package/src/ui/errors.ts +248 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/live-status.ts +259 -0
- package/src/ui/panels.ts +216 -0
- package/src/ui/progress.ts +139 -0
- package/src/ui/utils.ts +88 -0
package/src/ui/panels.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel rendering for summary displays
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { formatPercentage, icons } from './colors.js';
|
|
7
|
+
import { isTTY, centerText, padText, formatDuration } from './utils.js';
|
|
8
|
+
|
|
9
|
+
export interface SummaryData {
|
|
10
|
+
passed: number;
|
|
11
|
+
failed: number;
|
|
12
|
+
skipped: number;
|
|
13
|
+
successRate: number;
|
|
14
|
+
duration: number;
|
|
15
|
+
title?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StressSummaryData {
|
|
19
|
+
totalRequests: number;
|
|
20
|
+
successfulRequests: number;
|
|
21
|
+
failedRequests: number;
|
|
22
|
+
successRate: number;
|
|
23
|
+
duration: number;
|
|
24
|
+
avgLatency: number;
|
|
25
|
+
p50Latency: number;
|
|
26
|
+
p95Latency: number;
|
|
27
|
+
p99Latency: number;
|
|
28
|
+
throughput: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RedteamSummaryData {
|
|
32
|
+
totalCases: number;
|
|
33
|
+
safeResponses: number;
|
|
34
|
+
unsafeResponses: number;
|
|
35
|
+
blockedResponses: number;
|
|
36
|
+
errorResponses: number;
|
|
37
|
+
defenseRate: number;
|
|
38
|
+
severityBreakdown?: Record<string, number>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render a test results summary panel
|
|
43
|
+
*/
|
|
44
|
+
export function renderSummaryPanel(data: SummaryData): string {
|
|
45
|
+
const title = data.title ?? 'TEST RESULTS';
|
|
46
|
+
const width = 55;
|
|
47
|
+
|
|
48
|
+
if (!isTTY) {
|
|
49
|
+
// Plain text format for CI/CD
|
|
50
|
+
return [
|
|
51
|
+
`=== ${title} ===`,
|
|
52
|
+
`Passed: ${data.passed} Failed: ${data.failed} Skipped: ${data.skipped}`,
|
|
53
|
+
`Success Rate: ${data.successRate.toFixed(1)}%`,
|
|
54
|
+
`Duration: ${formatDuration(data.duration)}`,
|
|
55
|
+
].join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const border = '═'.repeat(width - 2);
|
|
59
|
+
|
|
60
|
+
const statsLine = ` ${icons.passed} Passed: ${data.passed} ${icons.failed} Failed: ${data.failed} ${icons.skipped} Skipped: ${data.skipped}`;
|
|
61
|
+
|
|
62
|
+
const lines = [
|
|
63
|
+
chalk.cyan(`╔${border}╗`),
|
|
64
|
+
chalk.cyan('║') + centerText(chalk.bold(title), width - 2) + chalk.cyan('║'),
|
|
65
|
+
chalk.cyan(`╠${border}╣`),
|
|
66
|
+
chalk.cyan('║') + padText(statsLine, width - 2) + chalk.cyan('║'),
|
|
67
|
+
chalk.cyan('║') +
|
|
68
|
+
padText(` Success Rate: ${formatPercentage(data.successRate)}`, width - 2) +
|
|
69
|
+
chalk.cyan('║'),
|
|
70
|
+
chalk.cyan('║') +
|
|
71
|
+
padText(` Duration: ${formatDuration(data.duration)}`, width - 2) +
|
|
72
|
+
chalk.cyan('║'),
|
|
73
|
+
chalk.cyan(`╚${border}╝`),
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Render a stress test summary panel
|
|
81
|
+
*/
|
|
82
|
+
export function renderStressSummaryPanel(data: StressSummaryData): string {
|
|
83
|
+
const width = 55;
|
|
84
|
+
|
|
85
|
+
if (!isTTY) {
|
|
86
|
+
return [
|
|
87
|
+
'=== STRESS TEST RESULTS ===',
|
|
88
|
+
`Total Requests: ${data.totalRequests}`,
|
|
89
|
+
`Successful: ${data.successfulRequests} (${data.successRate.toFixed(1)}%)`,
|
|
90
|
+
`Failed: ${data.failedRequests}`,
|
|
91
|
+
`Duration: ${formatDuration(data.duration)}`,
|
|
92
|
+
`Throughput: ${data.throughput.toFixed(1)} req/s`,
|
|
93
|
+
`Latency: avg=${data.avgLatency.toFixed(0)}ms p50=${data.p50Latency.toFixed(0)}ms p95=${data.p95Latency.toFixed(0)}ms p99=${data.p99Latency.toFixed(0)}ms`,
|
|
94
|
+
].join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const border = '═'.repeat(width - 2);
|
|
98
|
+
|
|
99
|
+
const lines = [
|
|
100
|
+
chalk.cyan(`╔${border}╗`),
|
|
101
|
+
chalk.cyan('║') + centerText(chalk.bold('STRESS TEST RESULTS'), width - 2) + chalk.cyan('║'),
|
|
102
|
+
chalk.cyan(`╠${border}╣`),
|
|
103
|
+
chalk.cyan('║') +
|
|
104
|
+
padText(` Total Requests: ${data.totalRequests}`, width - 2) +
|
|
105
|
+
chalk.cyan('║'),
|
|
106
|
+
chalk.cyan('║') +
|
|
107
|
+
padText(
|
|
108
|
+
` ${icons.passed} Successful: ${data.successfulRequests} ${icons.failed} Failed: ${data.failedRequests}`,
|
|
109
|
+
width - 2
|
|
110
|
+
) +
|
|
111
|
+
chalk.cyan('║'),
|
|
112
|
+
chalk.cyan('║') +
|
|
113
|
+
padText(` Success Rate: ${formatPercentage(data.successRate)}`, width - 2) +
|
|
114
|
+
chalk.cyan('║'),
|
|
115
|
+
chalk.cyan(`╠${border}╣`),
|
|
116
|
+
chalk.cyan('║') + centerText(chalk.dim('Performance Metrics'), width - 2) + chalk.cyan('║'),
|
|
117
|
+
chalk.cyan(`╠${border}╣`),
|
|
118
|
+
chalk.cyan('║') +
|
|
119
|
+
padText(` Throughput: ${chalk.bold(data.throughput.toFixed(1))} req/s`, width - 2) +
|
|
120
|
+
chalk.cyan('║'),
|
|
121
|
+
chalk.cyan('║') +
|
|
122
|
+
padText(` Duration: ${formatDuration(data.duration)}`, width - 2) +
|
|
123
|
+
chalk.cyan('║'),
|
|
124
|
+
chalk.cyan(`╠${border}╣`),
|
|
125
|
+
chalk.cyan('║') + centerText(chalk.dim('Latency'), width - 2) + chalk.cyan('║'),
|
|
126
|
+
chalk.cyan(`╠${border}╣`),
|
|
127
|
+
chalk.cyan('║') +
|
|
128
|
+
padText(` Average: ${data.avgLatency.toFixed(0)}ms`, width - 2) +
|
|
129
|
+
chalk.cyan('║'),
|
|
130
|
+
chalk.cyan('║') +
|
|
131
|
+
padText(
|
|
132
|
+
` p50: ${data.p50Latency.toFixed(0)}ms p95: ${data.p95Latency.toFixed(0)}ms p99: ${data.p99Latency.toFixed(0)}ms`,
|
|
133
|
+
width - 2
|
|
134
|
+
) +
|
|
135
|
+
chalk.cyan('║'),
|
|
136
|
+
chalk.cyan(`╚${border}╝`),
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Render a red team summary panel
|
|
144
|
+
*/
|
|
145
|
+
export function renderRedteamSummaryPanel(data: RedteamSummaryData): string {
|
|
146
|
+
const width = 55;
|
|
147
|
+
|
|
148
|
+
if (!isTTY) {
|
|
149
|
+
return [
|
|
150
|
+
'=== RED TEAM RESULTS ===',
|
|
151
|
+
`Total Cases: ${data.totalCases}`,
|
|
152
|
+
`Safe: ${data.safeResponses} Unsafe: ${data.unsafeResponses} Blocked: ${data.blockedResponses} Errors: ${data.errorResponses}`,
|
|
153
|
+
`Defense Rate: ${data.defenseRate.toFixed(1)}%`,
|
|
154
|
+
].join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const border = '═'.repeat(width - 2);
|
|
158
|
+
|
|
159
|
+
const defenseColor =
|
|
160
|
+
data.defenseRate >= 90 ? chalk.green : data.defenseRate >= 70 ? chalk.yellow : chalk.red;
|
|
161
|
+
|
|
162
|
+
const lines = [
|
|
163
|
+
chalk.magenta(`╔${border}╗`),
|
|
164
|
+
chalk.magenta('║') + centerText(chalk.bold('RED TEAM RESULTS'), width - 2) + chalk.magenta('║'),
|
|
165
|
+
chalk.magenta(`╠${border}╣`),
|
|
166
|
+
chalk.magenta('║') +
|
|
167
|
+
padText(` Total Cases: ${data.totalCases}`, width - 2) +
|
|
168
|
+
chalk.magenta('║'),
|
|
169
|
+
chalk.magenta('║') +
|
|
170
|
+
padText(
|
|
171
|
+
` ${chalk.green('✓')} Safe: ${data.safeResponses} ${chalk.red('✗')} Unsafe: ${data.unsafeResponses}`,
|
|
172
|
+
width - 2
|
|
173
|
+
) +
|
|
174
|
+
chalk.magenta('║'),
|
|
175
|
+
chalk.magenta('║') +
|
|
176
|
+
padText(
|
|
177
|
+
` ${chalk.cyan('⊘')} Blocked: ${data.blockedResponses} ${chalk.yellow('!')} Errors: ${data.errorResponses}`,
|
|
178
|
+
width - 2
|
|
179
|
+
) +
|
|
180
|
+
chalk.magenta('║'),
|
|
181
|
+
chalk.magenta(`╠${border}╣`),
|
|
182
|
+
chalk.magenta('║') +
|
|
183
|
+
padText(` Defense Rate: ${defenseColor(data.defenseRate.toFixed(1) + '%')}`, width - 2) +
|
|
184
|
+
chalk.magenta('║'),
|
|
185
|
+
chalk.magenta(`╚${border}╝`),
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Render a simple info box
|
|
193
|
+
*/
|
|
194
|
+
export function renderInfoBox(title: string, lines: string[]): string {
|
|
195
|
+
const width = 55;
|
|
196
|
+
|
|
197
|
+
if (!isTTY) {
|
|
198
|
+
return [`=== ${title} ===`, ...lines].join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const border = '─'.repeat(width - 2);
|
|
202
|
+
|
|
203
|
+
const result = [
|
|
204
|
+
chalk.gray(`┌─ ${chalk.bold(title)} ${border.slice(title.length + 3)}┐`),
|
|
205
|
+
chalk.gray('│') + ' '.repeat(width - 2) + chalk.gray('│'),
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
for (const line of lines) {
|
|
209
|
+
result.push(chalk.gray('│') + padText(` ${line}`, width - 2) + chalk.gray('│'));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
result.push(chalk.gray('│') + ' '.repeat(width - 2) + chalk.gray('│'));
|
|
213
|
+
result.push(chalk.gray(`└${border}┘`));
|
|
214
|
+
|
|
215
|
+
return result.join('\n');
|
|
216
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress bar rendering utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { isTTY, stripAnsi } from './utils.js';
|
|
7
|
+
|
|
8
|
+
export interface ProgressBarOptions {
|
|
9
|
+
/** Width of the progress bar in characters */
|
|
10
|
+
width?: number;
|
|
11
|
+
/** Character for filled portion */
|
|
12
|
+
filledChar?: string;
|
|
13
|
+
/** Character for empty portion */
|
|
14
|
+
emptyChar?: string;
|
|
15
|
+
/** Show percentage */
|
|
16
|
+
showPercentage?: boolean;
|
|
17
|
+
/** Show count (current/total) */
|
|
18
|
+
showCount?: boolean;
|
|
19
|
+
/** Label to show before the bar */
|
|
20
|
+
label?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const defaultOptions: Required<ProgressBarOptions> = {
|
|
24
|
+
width: 20,
|
|
25
|
+
filledChar: '█',
|
|
26
|
+
emptyChar: '░',
|
|
27
|
+
showPercentage: true,
|
|
28
|
+
showCount: true,
|
|
29
|
+
label: '',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render a progress bar
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* renderProgressBar(3, 10)
|
|
37
|
+
* // "████████░░░░░░░░░░░░ 3/10 (30%)"
|
|
38
|
+
*
|
|
39
|
+
* renderProgressBar(3, 10, { label: 'Running tests' })
|
|
40
|
+
* // "Running tests ████████░░░░░░░░░░░░ 3/10 (30%)"
|
|
41
|
+
*/
|
|
42
|
+
export function renderProgressBar(
|
|
43
|
+
current: number,
|
|
44
|
+
total: number,
|
|
45
|
+
options: ProgressBarOptions = {}
|
|
46
|
+
): string {
|
|
47
|
+
const opts = { ...defaultOptions, ...options };
|
|
48
|
+
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
|
|
49
|
+
const filled = total > 0 ? Math.round((current / total) * opts.width) : 0;
|
|
50
|
+
const empty = opts.width - filled;
|
|
51
|
+
|
|
52
|
+
// For non-TTY, use simple text format
|
|
53
|
+
if (!isTTY) {
|
|
54
|
+
const parts: string[] = [];
|
|
55
|
+
if (opts.label) parts.push(opts.label);
|
|
56
|
+
if (opts.showCount) parts.push(`${current}/${total}`);
|
|
57
|
+
if (opts.showPercentage) parts.push(`(${percentage}%)`);
|
|
58
|
+
return parts.join(' ');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const bar =
|
|
62
|
+
chalk.green(opts.filledChar.repeat(filled)) + chalk.gray(opts.emptyChar.repeat(empty));
|
|
63
|
+
|
|
64
|
+
const parts: string[] = [];
|
|
65
|
+
if (opts.label) parts.push(opts.label);
|
|
66
|
+
parts.push(bar);
|
|
67
|
+
if (opts.showCount) parts.push(`${current}/${total}`);
|
|
68
|
+
if (opts.showPercentage) parts.push(`(${percentage}%)`);
|
|
69
|
+
|
|
70
|
+
return parts.join(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a progress bar manager for updating in-place
|
|
75
|
+
*/
|
|
76
|
+
export class ProgressBar {
|
|
77
|
+
private current = 0;
|
|
78
|
+
private total: number;
|
|
79
|
+
private options: Required<ProgressBarOptions>;
|
|
80
|
+
private lastOutput = '';
|
|
81
|
+
|
|
82
|
+
constructor(total: number, options: ProgressBarOptions = {}) {
|
|
83
|
+
this.total = total;
|
|
84
|
+
this.options = { ...defaultOptions, ...options };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Update progress and render
|
|
89
|
+
*/
|
|
90
|
+
update(current: number): void {
|
|
91
|
+
this.current = current;
|
|
92
|
+
this.render();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Increment progress by one
|
|
97
|
+
*/
|
|
98
|
+
increment(): void {
|
|
99
|
+
this.update(this.current + 1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render the progress bar
|
|
104
|
+
*/
|
|
105
|
+
private render(): void {
|
|
106
|
+
const output = renderProgressBar(this.current, this.total, this.options);
|
|
107
|
+
|
|
108
|
+
if (isTTY) {
|
|
109
|
+
// Clear previous line and write new output
|
|
110
|
+
const clearLength = stripAnsi(this.lastOutput).length;
|
|
111
|
+
process.stdout.write('\r' + ' '.repeat(clearLength) + '\r');
|
|
112
|
+
process.stdout.write(output);
|
|
113
|
+
this.lastOutput = output;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Complete the progress bar and move to next line
|
|
119
|
+
*/
|
|
120
|
+
complete(): void {
|
|
121
|
+
this.update(this.total);
|
|
122
|
+
if (isTTY) {
|
|
123
|
+
process.stdout.write('\n');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Render a compact inline progress indicator
|
|
130
|
+
* Useful for spinners or status lines
|
|
131
|
+
*/
|
|
132
|
+
export function renderInlineProgress(current: number, total: number, label?: string): string {
|
|
133
|
+
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
|
|
134
|
+
|
|
135
|
+
if (label) {
|
|
136
|
+
return `${label}: ${current}/${total} (${percentage}%)`;
|
|
137
|
+
}
|
|
138
|
+
return `${current}/${total} (${percentage}%)`;
|
|
139
|
+
}
|
package/src/ui/utils.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI utility functions for terminal handling
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if stdout is a TTY (interactive terminal)
|
|
7
|
+
*/
|
|
8
|
+
export const isTTY = process.stdout.isTTY ?? false;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get terminal width, with fallback for non-TTY environments
|
|
12
|
+
*/
|
|
13
|
+
export function getTerminalWidth(): number {
|
|
14
|
+
return process.stdout.columns ?? 80;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render different output based on TTY availability
|
|
19
|
+
* @param ttyOutput Output for interactive terminals
|
|
20
|
+
* @param plainOutput Output for non-TTY (CI/CD) environments
|
|
21
|
+
*/
|
|
22
|
+
export function renderConditional(ttyOutput: string, plainOutput: string): string {
|
|
23
|
+
return isTTY ? ttyOutput : plainOutput;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Center text within a given width
|
|
28
|
+
*/
|
|
29
|
+
export function centerText(text: string, width: number): string {
|
|
30
|
+
const visibleLength = stripAnsi(text).length;
|
|
31
|
+
const padding = Math.max(0, Math.floor((width - visibleLength) / 2));
|
|
32
|
+
const rightPadding = width - padding - visibleLength;
|
|
33
|
+
return ' '.repeat(padding) + text + ' '.repeat(Math.max(0, rightPadding));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pad text to a specific width (accounting for ANSI codes)
|
|
38
|
+
*/
|
|
39
|
+
export function padText(
|
|
40
|
+
text: string,
|
|
41
|
+
width: number,
|
|
42
|
+
align: 'left' | 'right' | 'center' = 'left'
|
|
43
|
+
): string {
|
|
44
|
+
const visibleLength = stripAnsi(text).length;
|
|
45
|
+
const paddingNeeded = Math.max(0, width - visibleLength);
|
|
46
|
+
|
|
47
|
+
if (align === 'center') {
|
|
48
|
+
return centerText(text, width);
|
|
49
|
+
} else if (align === 'right') {
|
|
50
|
+
return ' '.repeat(paddingNeeded) + text;
|
|
51
|
+
}
|
|
52
|
+
return text + ' '.repeat(paddingNeeded);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Strip ANSI escape codes from string (for length calculations)
|
|
57
|
+
*/
|
|
58
|
+
export function stripAnsi(str: string): string {
|
|
59
|
+
// eslint-disable-next-line no-control-regex
|
|
60
|
+
return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Truncate text to fit within a width, adding ellipsis if needed
|
|
65
|
+
*/
|
|
66
|
+
export function truncate(text: string, maxWidth: number): string {
|
|
67
|
+
const visibleLength = stripAnsi(text).length;
|
|
68
|
+
if (visibleLength <= maxWidth) return text;
|
|
69
|
+
return text.slice(0, maxWidth - 1) + '…';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Format duration in a human-readable way
|
|
74
|
+
*/
|
|
75
|
+
export function formatDuration(ms: number): string {
|
|
76
|
+
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
|
77
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
78
|
+
const minutes = Math.floor(ms / 60000);
|
|
79
|
+
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
|
80
|
+
return `${minutes}m ${seconds}s`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Format a number with commas for readability
|
|
85
|
+
*/
|
|
86
|
+
export function formatNumber(num: number): string {
|
|
87
|
+
return num.toLocaleString();
|
|
88
|
+
}
|