ralphctl 0.1.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/CHANGELOG.md +94 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/bin/ralphctl +13 -0
- package/package.json +92 -0
- package/schemas/config.schema.json +20 -0
- package/schemas/ideate-output.schema.json +22 -0
- package/schemas/projects.schema.json +53 -0
- package/schemas/requirements-output.schema.json +24 -0
- package/schemas/sprint.schema.json +109 -0
- package/schemas/task-import.schema.json +49 -0
- package/schemas/tasks.schema.json +72 -0
- package/src/ai/executor.ts +973 -0
- package/src/ai/lifecycle.ts +45 -0
- package/src/ai/parser.ts +40 -0
- package/src/ai/permissions.ts +207 -0
- package/src/ai/process-manager.ts +248 -0
- package/src/ai/prompts/ideate-auto.md +144 -0
- package/src/ai/prompts/ideate.md +165 -0
- package/src/ai/prompts/index.ts +89 -0
- package/src/ai/prompts/plan-auto.md +131 -0
- package/src/ai/prompts/plan-common.md +157 -0
- package/src/ai/prompts/plan-interactive.md +190 -0
- package/src/ai/prompts/task-execution.md +159 -0
- package/src/ai/prompts/ticket-refine.md +230 -0
- package/src/ai/rate-limiter.ts +89 -0
- package/src/ai/runner.ts +478 -0
- package/src/ai/session.ts +319 -0
- package/src/ai/task-context.ts +270 -0
- package/src/cli-metadata.ts +7 -0
- package/src/cli.ts +65 -0
- package/src/commands/completion/index.ts +33 -0
- package/src/commands/config/config.ts +58 -0
- package/src/commands/config/index.ts +33 -0
- package/src/commands/dashboard/dashboard.ts +5 -0
- package/src/commands/dashboard/index.ts +6 -0
- package/src/commands/doctor/doctor.ts +271 -0
- package/src/commands/doctor/index.ts +25 -0
- package/src/commands/progress/index.ts +25 -0
- package/src/commands/progress/log.ts +64 -0
- package/src/commands/progress/show.ts +14 -0
- package/src/commands/project/add.ts +336 -0
- package/src/commands/project/index.ts +104 -0
- package/src/commands/project/list.ts +31 -0
- package/src/commands/project/remove.ts +43 -0
- package/src/commands/project/repo.ts +118 -0
- package/src/commands/project/show.ts +49 -0
- package/src/commands/sprint/close.ts +180 -0
- package/src/commands/sprint/context.ts +109 -0
- package/src/commands/sprint/create.ts +60 -0
- package/src/commands/sprint/current.ts +75 -0
- package/src/commands/sprint/delete.ts +72 -0
- package/src/commands/sprint/health.ts +229 -0
- package/src/commands/sprint/ideate.ts +496 -0
- package/src/commands/sprint/index.ts +226 -0
- package/src/commands/sprint/list.ts +86 -0
- package/src/commands/sprint/plan-utils.ts +207 -0
- package/src/commands/sprint/plan.ts +549 -0
- package/src/commands/sprint/refine.ts +359 -0
- package/src/commands/sprint/requirements.ts +58 -0
- package/src/commands/sprint/show.ts +140 -0
- package/src/commands/sprint/start.ts +119 -0
- package/src/commands/sprint/switch.ts +20 -0
- package/src/commands/task/add.ts +316 -0
- package/src/commands/task/import.ts +150 -0
- package/src/commands/task/index.ts +123 -0
- package/src/commands/task/list.ts +145 -0
- package/src/commands/task/next.ts +45 -0
- package/src/commands/task/remove.ts +47 -0
- package/src/commands/task/reorder.ts +45 -0
- package/src/commands/task/show.ts +111 -0
- package/src/commands/task/status.ts +99 -0
- package/src/commands/ticket/add.ts +265 -0
- package/src/commands/ticket/edit.ts +166 -0
- package/src/commands/ticket/index.ts +114 -0
- package/src/commands/ticket/list.ts +128 -0
- package/src/commands/ticket/refine-utils.ts +89 -0
- package/src/commands/ticket/refine.ts +268 -0
- package/src/commands/ticket/remove.ts +48 -0
- package/src/commands/ticket/show.ts +74 -0
- package/src/completion/handle.ts +30 -0
- package/src/completion/resolver.ts +241 -0
- package/src/interactive/dashboard.ts +268 -0
- package/src/interactive/escapable.ts +81 -0
- package/src/interactive/file-browser.ts +153 -0
- package/src/interactive/index.ts +429 -0
- package/src/interactive/menu.ts +403 -0
- package/src/interactive/selectors.ts +273 -0
- package/src/interactive/wizard.ts +221 -0
- package/src/providers/claude.ts +53 -0
- package/src/providers/copilot.ts +86 -0
- package/src/providers/index.ts +43 -0
- package/src/providers/types.ts +85 -0
- package/src/schemas/index.ts +130 -0
- package/src/store/config.ts +74 -0
- package/src/store/progress.ts +230 -0
- package/src/store/project.ts +276 -0
- package/src/store/sprint.ts +229 -0
- package/src/store/task.ts +443 -0
- package/src/store/ticket.ts +178 -0
- package/src/theme/index.ts +215 -0
- package/src/theme/ui.ts +872 -0
- package/src/utils/detect-scripts.ts +247 -0
- package/src/utils/editor-input.ts +41 -0
- package/src/utils/editor.ts +37 -0
- package/src/utils/exit-codes.ts +27 -0
- package/src/utils/file-lock.ts +135 -0
- package/src/utils/git.ts +185 -0
- package/src/utils/ids.ts +37 -0
- package/src/utils/issue-fetch.ts +244 -0
- package/src/utils/json-extract.ts +62 -0
- package/src/utils/multiline.ts +61 -0
- package/src/utils/path-selector.ts +236 -0
- package/src/utils/paths.ts +108 -0
- package/src/utils/provider.ts +34 -0
- package/src/utils/requirements-export.ts +63 -0
- package/src/utils/storage.ts +107 -0
- package/tsconfig.json +25 -0
package/src/theme/ui.ts
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import ora, { type Ora } from 'ora';
|
|
2
|
+
import { banner, type ColorFn, colors, getRandomQuote, getStatusEmoji, gradients, isColorSupported } from './index.ts';
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// ICONS
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
/** Emoji for interactive prompts (distinct from ASCII icons) */
|
|
9
|
+
export const emoji = {
|
|
10
|
+
donut: '🍩',
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
/** Icons for visual hierarchy (ASCII only for professional look) */
|
|
14
|
+
export const icons = {
|
|
15
|
+
// Entities
|
|
16
|
+
sprint: '>',
|
|
17
|
+
ticket: '#',
|
|
18
|
+
task: '*',
|
|
19
|
+
project: '@',
|
|
20
|
+
|
|
21
|
+
// Actions
|
|
22
|
+
edit: '>',
|
|
23
|
+
|
|
24
|
+
// Status indicators
|
|
25
|
+
success: '+',
|
|
26
|
+
error: 'x',
|
|
27
|
+
warning: '!',
|
|
28
|
+
info: 'i',
|
|
29
|
+
tip: '?',
|
|
30
|
+
active: '*',
|
|
31
|
+
inactive: 'o',
|
|
32
|
+
bullet: '-',
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// LOGGING UTILITIES (consistent formatting with 2-space indent)
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
const INDENT = ' ';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Structured logging utilities for consistent output
|
|
43
|
+
*/
|
|
44
|
+
export const log = {
|
|
45
|
+
/** Info message with icon */
|
|
46
|
+
info(message: string): void {
|
|
47
|
+
console.log(`${INDENT}${colors.info(icons.info)} ${message}`);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/** Success message with icon */
|
|
51
|
+
success(message: string): void {
|
|
52
|
+
console.log(`${INDENT}${colors.success(icons.success)} ${message}`);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/** Warning message with icon */
|
|
56
|
+
warn(message: string): void {
|
|
57
|
+
console.log(`${INDENT}${colors.warning(icons.warning)} ${message}`);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/** Error message with icon */
|
|
61
|
+
error(message: string): void {
|
|
62
|
+
console.log(`${INDENT}${colors.error(icons.error)} ${message}`);
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/** Dimmed/muted message */
|
|
66
|
+
dim(message: string): void {
|
|
67
|
+
console.log(`${INDENT}${colors.muted(message)}`);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/** List item with bullet */
|
|
71
|
+
item(message: string): void {
|
|
72
|
+
console.log(`${INDENT}${INDENT}${colors.muted(icons.bullet)} ${message}`);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/** Success list item */
|
|
76
|
+
itemSuccess(message: string): void {
|
|
77
|
+
console.log(`${INDENT}${INDENT}${colors.success(icons.success)} ${message}`);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/** Error list item */
|
|
81
|
+
itemError(message: string, detail?: string): void {
|
|
82
|
+
console.log(`${INDENT}${INDENT}${colors.error(icons.error)} ${message}`);
|
|
83
|
+
if (detail) {
|
|
84
|
+
console.log(`${INDENT}${INDENT} ${colors.muted(detail)}`);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
/** Raw text with indent */
|
|
89
|
+
raw(message: string, indentLevel = 1): void {
|
|
90
|
+
const prefix = INDENT.repeat(indentLevel);
|
|
91
|
+
console.log(`${prefix}${message}`);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/** Newline for spacing */
|
|
95
|
+
newline(): void {
|
|
96
|
+
console.log('');
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// BOX & HEADER DRAWING
|
|
102
|
+
// ============================================================================
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Print a simple header with icon and title
|
|
106
|
+
*/
|
|
107
|
+
export function printHeader(title: string, icon?: string): void {
|
|
108
|
+
const displayIcon = icon ?? emoji.donut;
|
|
109
|
+
console.log('');
|
|
110
|
+
console.log(` ${displayIcon} ${colors.highlight(title)}`);
|
|
111
|
+
console.log(colors.muted(` ${'─'.repeat(40)}`));
|
|
112
|
+
console.log('');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Print a separator line
|
|
117
|
+
*/
|
|
118
|
+
export function printSeparator(width = 40): void {
|
|
119
|
+
console.log(`${INDENT}${colors.muted('─'.repeat(width))}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// BOX-DRAWING CHARACTERS & UTILITIES
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
126
|
+
/** Box-drawing character sets */
|
|
127
|
+
export const boxChars = {
|
|
128
|
+
/** Light box-drawing (default) */
|
|
129
|
+
light: {
|
|
130
|
+
topLeft: '┌',
|
|
131
|
+
topRight: '┐',
|
|
132
|
+
bottomLeft: '└',
|
|
133
|
+
bottomRight: '┘',
|
|
134
|
+
horizontal: '─',
|
|
135
|
+
vertical: '│',
|
|
136
|
+
teeRight: '├',
|
|
137
|
+
teeLeft: '┤',
|
|
138
|
+
teeDown: '┬',
|
|
139
|
+
teeUp: '┴',
|
|
140
|
+
cross: '┼',
|
|
141
|
+
},
|
|
142
|
+
/** Rounded corners */
|
|
143
|
+
rounded: {
|
|
144
|
+
topLeft: '╭',
|
|
145
|
+
topRight: '╮',
|
|
146
|
+
bottomLeft: '╰',
|
|
147
|
+
bottomRight: '╯',
|
|
148
|
+
horizontal: '─',
|
|
149
|
+
vertical: '│',
|
|
150
|
+
teeRight: '├',
|
|
151
|
+
teeLeft: '┤',
|
|
152
|
+
teeDown: '┬',
|
|
153
|
+
teeUp: '┴',
|
|
154
|
+
cross: '┼',
|
|
155
|
+
},
|
|
156
|
+
/** Heavy box-drawing */
|
|
157
|
+
heavy: {
|
|
158
|
+
topLeft: '┏',
|
|
159
|
+
topRight: '┓',
|
|
160
|
+
bottomLeft: '┗',
|
|
161
|
+
bottomRight: '┛',
|
|
162
|
+
horizontal: '━',
|
|
163
|
+
vertical: '┃',
|
|
164
|
+
teeRight: '┣',
|
|
165
|
+
teeLeft: '┫',
|
|
166
|
+
teeDown: '┳',
|
|
167
|
+
teeUp: '┻',
|
|
168
|
+
cross: '╋',
|
|
169
|
+
},
|
|
170
|
+
} as const;
|
|
171
|
+
|
|
172
|
+
export type BoxStyle = keyof typeof boxChars;
|
|
173
|
+
|
|
174
|
+
// Comprehensive ANSI escape sequence regex (CSI, OSC, and character set sequences)
|
|
175
|
+
// eslint-disable-next-line no-control-regex
|
|
176
|
+
const ANSI_REGEX = /\x1B(?:\[[0-9;]*[A-Za-z]|\][^\x07]*\x07|\([A-Z])/g;
|
|
177
|
+
|
|
178
|
+
/** Strip ANSI escape codes from a string for width calculation */
|
|
179
|
+
function stripAnsi(s: string): string {
|
|
180
|
+
return s.replace(ANSI_REGEX, '');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Sanitize a user-controlled string for safe terminal display.
|
|
185
|
+
* Strips all ANSI escape sequences that could manipulate the terminal.
|
|
186
|
+
*/
|
|
187
|
+
export function sanitizeForDisplay(s: string): string {
|
|
188
|
+
return s.replace(ANSI_REGEX, '');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Minimum inner width for rendered boxes and cards */
|
|
192
|
+
export const MIN_BOX_WIDTH = 20;
|
|
193
|
+
|
|
194
|
+
/** Default terminal width fallback when not a TTY */
|
|
195
|
+
const DEFAULT_TERMINAL_WIDTH = 80;
|
|
196
|
+
|
|
197
|
+
/** Get the current terminal width, clamped to a reasonable minimum */
|
|
198
|
+
function getTerminalWidth(): number {
|
|
199
|
+
return process.stdout.columns || DEFAULT_TERMINAL_WIDTH;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Word-wrap a plain-text line to fit within maxWidth visible characters.
|
|
204
|
+
* Splits on word boundaries where possible, hard-breaks long words.
|
|
205
|
+
* Preserves leading whitespace on the first line; continuation lines get the same indent.
|
|
206
|
+
*/
|
|
207
|
+
function wrapLine(line: string, maxWidth: number): string[] {
|
|
208
|
+
const visible = stripAnsi(line);
|
|
209
|
+
if (visible.length <= maxWidth) return [line];
|
|
210
|
+
|
|
211
|
+
// Detect leading indent
|
|
212
|
+
const indentMatch = /^(\s*)/.exec(visible);
|
|
213
|
+
const indent = indentMatch?.[1] ?? '';
|
|
214
|
+
const indentLen = indent.length;
|
|
215
|
+
const wrapWidth = maxWidth - indentLen;
|
|
216
|
+
|
|
217
|
+
if (wrapWidth <= 0) return [line]; // Can't wrap if indent eats everything
|
|
218
|
+
|
|
219
|
+
const words = visible.trimStart().split(/(\s+)/);
|
|
220
|
+
const wrapped: string[] = [];
|
|
221
|
+
let current = '';
|
|
222
|
+
|
|
223
|
+
for (const word of words) {
|
|
224
|
+
if (current.length + word.length <= wrapWidth) {
|
|
225
|
+
current += word;
|
|
226
|
+
} else if (current.length === 0) {
|
|
227
|
+
// Single word longer than wrapWidth — hard break
|
|
228
|
+
for (let i = 0; i < word.length; i += wrapWidth) {
|
|
229
|
+
wrapped.push(indent + word.slice(i, i + wrapWidth));
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
wrapped.push(indent + current.trimEnd());
|
|
233
|
+
current = word.trimStart();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (current.trimEnd().length > 0) {
|
|
237
|
+
wrapped.push(indent + current.trimEnd());
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return wrapped.length > 0 ? wrapped : [line];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Standard label width for detail views (accommodates labels like "External ID:") */
|
|
244
|
+
export const DETAIL_LABEL_WIDTH = 14;
|
|
245
|
+
|
|
246
|
+
/** Draw a horizontal line with optional label */
|
|
247
|
+
export function horizontalLine(width: number, style: BoxStyle = 'light'): string {
|
|
248
|
+
return boxChars[style].horizontal.repeat(width);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Draw a vertical line character */
|
|
252
|
+
export function verticalLine(style: BoxStyle = 'light'): string {
|
|
253
|
+
return boxChars[style].vertical;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Render a box with border around content lines.
|
|
258
|
+
* Automatically wraps content to fit within the terminal width.
|
|
259
|
+
*/
|
|
260
|
+
export function renderBox(
|
|
261
|
+
lines: string[],
|
|
262
|
+
options: { style?: BoxStyle; padding?: number; colorFn?: ColorFn } = {}
|
|
263
|
+
): string {
|
|
264
|
+
const { style = 'rounded', padding = 1, colorFn = colors.muted } = options;
|
|
265
|
+
const chars = boxChars[style];
|
|
266
|
+
const pad = ' '.repeat(padding);
|
|
267
|
+
|
|
268
|
+
// Clamp to terminal width (border chars = 2, plus padding on each side)
|
|
269
|
+
const termWidth = getTerminalWidth();
|
|
270
|
+
const maxInnerWidth = Math.max(MIN_BOX_WIDTH, termWidth - 2);
|
|
271
|
+
|
|
272
|
+
// Wrap lines that exceed available content width
|
|
273
|
+
const maxContentWidth = maxInnerWidth - padding * 2;
|
|
274
|
+
const wrappedLines = lines.flatMap((l) => wrapLine(l, maxContentWidth));
|
|
275
|
+
|
|
276
|
+
const contentWidths = wrappedLines.map((l) => stripAnsi(l).length);
|
|
277
|
+
const innerWidth = Math.min(Math.max(...contentWidths, MIN_BOX_WIDTH) + padding * 2, maxInnerWidth);
|
|
278
|
+
|
|
279
|
+
const result: string[] = [];
|
|
280
|
+
result.push(colorFn(chars.topLeft + chars.horizontal.repeat(innerWidth) + chars.topRight));
|
|
281
|
+
|
|
282
|
+
for (const line of wrappedLines) {
|
|
283
|
+
const visibleLen = stripAnsi(line).length;
|
|
284
|
+
const rightPad = ' '.repeat(Math.max(0, innerWidth - padding * 2 - visibleLen));
|
|
285
|
+
result.push(colorFn(chars.vertical) + pad + line + rightPad + pad + colorFn(chars.vertical));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
result.push(colorFn(chars.bottomLeft + chars.horizontal.repeat(innerWidth) + chars.bottomRight));
|
|
289
|
+
return result.join('\n');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Render a card with title bar and content body.
|
|
294
|
+
* Automatically wraps content lines to fit within the terminal width.
|
|
295
|
+
*/
|
|
296
|
+
export function renderCard(
|
|
297
|
+
title: string,
|
|
298
|
+
lines: string[],
|
|
299
|
+
options: { style?: BoxStyle; colorFn?: ColorFn } = {}
|
|
300
|
+
): string {
|
|
301
|
+
const { style = 'rounded', colorFn = colors.muted } = options;
|
|
302
|
+
const chars = boxChars[style];
|
|
303
|
+
|
|
304
|
+
// Clamp to terminal width (border chars + 1 padding on each side = 4 total)
|
|
305
|
+
const termWidth = getTerminalWidth();
|
|
306
|
+
const maxInnerWidth = Math.max(MIN_BOX_WIDTH, termWidth - 4);
|
|
307
|
+
|
|
308
|
+
const safeTitle = sanitizeForDisplay(title);
|
|
309
|
+
const titleWidth = Math.min(safeTitle.length, maxInnerWidth - 2);
|
|
310
|
+
|
|
311
|
+
// Wrap lines that exceed available content width (innerWidth minus 2 for padding)
|
|
312
|
+
const wrappedLines = lines.flatMap((l) => wrapLine(l, maxInnerWidth - 2));
|
|
313
|
+
|
|
314
|
+
const contentWidths = wrappedLines.map((l) => stripAnsi(l).length);
|
|
315
|
+
const innerWidth = Math.min(Math.max(...contentWidths, titleWidth, MIN_BOX_WIDTH) + 2, maxInnerWidth);
|
|
316
|
+
|
|
317
|
+
const result: string[] = [];
|
|
318
|
+
// Top border
|
|
319
|
+
result.push(colorFn(chars.topLeft + chars.horizontal.repeat(innerWidth) + chars.topRight));
|
|
320
|
+
// Title line
|
|
321
|
+
const titlePad = ' '.repeat(Math.max(0, innerWidth - titleWidth - 2));
|
|
322
|
+
result.push(colorFn(chars.vertical) + ' ' + colors.highlight(safeTitle) + titlePad + ' ' + colorFn(chars.vertical));
|
|
323
|
+
// Separator
|
|
324
|
+
result.push(colorFn(chars.teeRight + chars.horizontal.repeat(innerWidth) + chars.teeLeft));
|
|
325
|
+
// Content lines
|
|
326
|
+
for (const line of wrappedLines) {
|
|
327
|
+
const visibleLen = stripAnsi(line).length;
|
|
328
|
+
const rightPad = ' '.repeat(Math.max(0, innerWidth - visibleLen - 2));
|
|
329
|
+
result.push(colorFn(chars.vertical) + ' ' + line + rightPad + ' ' + colorFn(chars.vertical));
|
|
330
|
+
}
|
|
331
|
+
// Bottom border
|
|
332
|
+
result.push(colorFn(chars.bottomLeft + chars.horizontal.repeat(innerWidth) + chars.bottomRight));
|
|
333
|
+
return result.join('\n');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ============================================================================
|
|
337
|
+
// BANNER & WELCOME
|
|
338
|
+
// ============================================================================
|
|
339
|
+
|
|
340
|
+
// Re-export getRandomQuote for external use
|
|
341
|
+
export { getRandomQuote } from './index.ts';
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Show the themed banner with gradient styling.
|
|
345
|
+
* Falls back to flat color when colors are not supported.
|
|
346
|
+
*/
|
|
347
|
+
export function showBanner(): void {
|
|
348
|
+
if (isColorSupported) {
|
|
349
|
+
console.log(gradients.donut.multiline(banner.art));
|
|
350
|
+
} else {
|
|
351
|
+
console.log(banner.art);
|
|
352
|
+
}
|
|
353
|
+
const quote = getRandomQuote();
|
|
354
|
+
console.log(colors.muted(` "${quote}"\n`));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ============================================================================
|
|
358
|
+
// SECTION HEADERS (simpler style)
|
|
359
|
+
// ============================================================================
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Format a section header with icon
|
|
363
|
+
*/
|
|
364
|
+
export function section(title: string, icon?: string): string {
|
|
365
|
+
const prefix = icon ? `${icon} ` : '';
|
|
366
|
+
return '\n' + colors.info(prefix + title) + '\n';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Format a subsection header
|
|
371
|
+
*/
|
|
372
|
+
export function subsection(title: string): string {
|
|
373
|
+
return colors.muted(` ${title}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ============================================================================
|
|
377
|
+
// FIELD FORMATTING (consistent alignment)
|
|
378
|
+
// ============================================================================
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Format a labeled field with consistent padding
|
|
382
|
+
* @param label - Field label (e.g., "ID", "Name")
|
|
383
|
+
* @param value - Field value
|
|
384
|
+
* @param labelWidth - Width for label column (default 12)
|
|
385
|
+
*/
|
|
386
|
+
export function field(label: string, value: string, labelWidth = 12): string {
|
|
387
|
+
const paddedLabel = (label + ':').padEnd(labelWidth);
|
|
388
|
+
return `${INDENT}${colors.muted(paddedLabel)} ${value}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Format a detail field for card content (trimmed for consistent alignment).
|
|
393
|
+
* Used in show commands to build card content lines.
|
|
394
|
+
*/
|
|
395
|
+
export function labelValue(label: string, value: string, labelWidth = DETAIL_LABEL_WIDTH): string {
|
|
396
|
+
return field(label, value, labelWidth).trimStart();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Format a multiline field with proper indentation
|
|
401
|
+
* First line shows label, subsequent lines are indented to align
|
|
402
|
+
*/
|
|
403
|
+
export function fieldMultiline(label: string, value: string, labelWidth = 12): string {
|
|
404
|
+
const lines = value.split('\n');
|
|
405
|
+
const paddedLabel = (label + ':').padEnd(labelWidth);
|
|
406
|
+
const indent = INDENT + ' '.repeat(labelWidth + 1);
|
|
407
|
+
|
|
408
|
+
if (lines.length === 1) {
|
|
409
|
+
return `${INDENT}${colors.muted(paddedLabel)} ${value}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const firstLine = lines[0] ?? '';
|
|
413
|
+
const result: string[] = [];
|
|
414
|
+
result.push(`${INDENT}${colors.muted(paddedLabel)} ${firstLine}`);
|
|
415
|
+
for (let i = 1; i < lines.length; i++) {
|
|
416
|
+
const line = lines[i] ?? '';
|
|
417
|
+
result.push(`${indent}${line}`);
|
|
418
|
+
}
|
|
419
|
+
return result.join('\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ============================================================================
|
|
423
|
+
// STATUS FORMATTING
|
|
424
|
+
// ============================================================================
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Format task status for display
|
|
428
|
+
*/
|
|
429
|
+
export function formatTaskStatus(status: 'todo' | 'in_progress' | 'done'): string {
|
|
430
|
+
const emoji = getStatusEmoji(status);
|
|
431
|
+
const labels: Record<string, string> = {
|
|
432
|
+
todo: 'To Do',
|
|
433
|
+
in_progress: 'In Progress',
|
|
434
|
+
done: 'Done',
|
|
435
|
+
};
|
|
436
|
+
const statusColors: Record<string, ColorFn> = {
|
|
437
|
+
todo: colors.muted,
|
|
438
|
+
in_progress: colors.warning,
|
|
439
|
+
done: colors.success,
|
|
440
|
+
};
|
|
441
|
+
const colorFn = statusColors[status] ?? colors.muted;
|
|
442
|
+
return colorFn(`${emoji} ${labels[status] ?? status}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Format sprint status for display
|
|
447
|
+
*/
|
|
448
|
+
export function formatSprintStatus(status: 'draft' | 'active' | 'closed'): string {
|
|
449
|
+
const emoji = getStatusEmoji(status);
|
|
450
|
+
const labels: Record<string, string> = {
|
|
451
|
+
draft: 'Draft',
|
|
452
|
+
active: 'Active',
|
|
453
|
+
closed: 'Closed',
|
|
454
|
+
};
|
|
455
|
+
const statusColors: Record<string, ColorFn> = {
|
|
456
|
+
draft: colors.warning,
|
|
457
|
+
active: colors.success,
|
|
458
|
+
closed: colors.muted,
|
|
459
|
+
};
|
|
460
|
+
const colorFn = statusColors[status] ?? colors.muted;
|
|
461
|
+
return colorFn(`${emoji} ${labels[status] ?? status}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Format a badge (inline status indicator)
|
|
466
|
+
*/
|
|
467
|
+
export function badge(text: string, type: 'success' | 'warning' | 'error' | 'muted' = 'muted'): string {
|
|
468
|
+
const colorFn = colors[type];
|
|
469
|
+
return colorFn(`[${text}]`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ============================================================================
|
|
473
|
+
// SUMMARY & STATS
|
|
474
|
+
// ============================================================================
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Print a summary line with label and value
|
|
478
|
+
*/
|
|
479
|
+
export function printSummary(items: [string, string | number][]): void {
|
|
480
|
+
printSeparator();
|
|
481
|
+
for (const [label, value] of items) {
|
|
482
|
+
console.log(`${INDENT}${colors.muted(label)} ${colors.highlight(String(value))}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Print a count summary (e.g., "5/10 tasks done (50%)")
|
|
488
|
+
*/
|
|
489
|
+
export function printCountSummary(label: string, done: number, total: number): void {
|
|
490
|
+
const percent = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
491
|
+
const color = percent === 100 ? colors.success : percent > 50 ? colors.warning : colors.muted;
|
|
492
|
+
printSeparator();
|
|
493
|
+
console.log(`${INDENT}${label} ${color(`${String(done)}/${String(total)} (${String(percent)}%)`)}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ============================================================================
|
|
497
|
+
// MESSAGES
|
|
498
|
+
// ============================================================================
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Show a success message with optional details
|
|
502
|
+
*/
|
|
503
|
+
export function showSuccess(message: string, details?: [string, string][]): void {
|
|
504
|
+
console.log('\n' + `${INDENT}${colors.success(icons.success)} ${colors.success(message)}`);
|
|
505
|
+
if (details) {
|
|
506
|
+
console.log(details.map(([label, value]) => field(label, value)).join('\n'));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Show an error message
|
|
512
|
+
*/
|
|
513
|
+
export function showError(message: string): void {
|
|
514
|
+
console.log('\n' + `${INDENT}${colors.error(icons.error)} ${colors.error(message)}`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Show an info message
|
|
519
|
+
*/
|
|
520
|
+
export function showInfo(message: string): void {
|
|
521
|
+
console.log(`${INDENT}${colors.info(icons.info)} ${colors.info(message)}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Show a warning message
|
|
526
|
+
*/
|
|
527
|
+
export function showWarning(message: string): void {
|
|
528
|
+
console.log(`${INDENT}${colors.warning(icons.warning)} ${colors.warning(message)}`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Show a tip/hint
|
|
533
|
+
*/
|
|
534
|
+
export function showTip(message: string): void {
|
|
535
|
+
console.log(`${INDENT}${colors.muted(icons.tip + ' ' + message)}`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Show an empty state with helpful next action
|
|
540
|
+
*/
|
|
541
|
+
export function showEmpty(what: string, hint?: string): void {
|
|
542
|
+
console.log('\n' + `${INDENT}${colors.muted(icons.inactive)} ${colors.muted(`No ${what} yet.`)}`);
|
|
543
|
+
if (hint) {
|
|
544
|
+
console.log(`${INDENT} ${colors.muted(icons.tip + ' ' + hint)}\n`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ============================================================================
|
|
549
|
+
// NEXT STEPS / HINTS
|
|
550
|
+
// ============================================================================
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Show next step suggestion (single)
|
|
554
|
+
*/
|
|
555
|
+
export function showNextStep(command: string, description?: string): void {
|
|
556
|
+
const desc = description ? ` ${colors.muted('- ' + description)}` : '';
|
|
557
|
+
console.log(`${INDENT}${colors.muted('→')} ${colors.highlight(command)}${desc}`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Show multiple next step suggestions compactly
|
|
562
|
+
*/
|
|
563
|
+
export function showNextSteps(steps: [command: string, description?: string][]): void {
|
|
564
|
+
for (const [command, description] of steps) {
|
|
565
|
+
showNextStep(command, description);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ============================================================================
|
|
570
|
+
// FORMATTING HELPERS
|
|
571
|
+
// ============================================================================
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Format muted/secondary text
|
|
575
|
+
*/
|
|
576
|
+
export function formatMuted(text: string): string {
|
|
577
|
+
return colors.muted(text);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Show a random Ralph quote
|
|
582
|
+
*/
|
|
583
|
+
export function showRandomQuote(): void {
|
|
584
|
+
const quote = getRandomQuote();
|
|
585
|
+
console.log(colors.muted(` "${quote}"`));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ============================================================================
|
|
589
|
+
// SPINNER
|
|
590
|
+
// ============================================================================
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Create a spinner for async operations
|
|
594
|
+
*/
|
|
595
|
+
export function createSpinner(text: string): Ora {
|
|
596
|
+
return ora({
|
|
597
|
+
text,
|
|
598
|
+
color: 'yellow',
|
|
599
|
+
prefixText: INDENT,
|
|
600
|
+
// Disable stdin-discarder: it puts stdin in raw mode, which swallows
|
|
601
|
+
// Ctrl+C (byte 0x03) instead of letting the OS deliver a real SIGINT.
|
|
602
|
+
discardStdin: false,
|
|
603
|
+
spinner: {
|
|
604
|
+
interval: 80,
|
|
605
|
+
frames: Array(8)
|
|
606
|
+
.fill(emoji.donut)
|
|
607
|
+
.map((d: string, i) => (i % 2 === 0 ? colors.highlight(d) : colors.muted(d))),
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ============================================================================
|
|
613
|
+
// ANIMATION HELPERS
|
|
614
|
+
// ============================================================================
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Check if the current output supports interactive features (TTY).
|
|
618
|
+
* Returns false for piped output, CI environments, or when NO_COLOR is set.
|
|
619
|
+
*/
|
|
620
|
+
export function isTTY(): boolean {
|
|
621
|
+
if (!process.stdout.isTTY || process.env['NO_COLOR']) return false;
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Typewriter effect: prints text one character at a time.
|
|
627
|
+
* Falls back to instant print when not a TTY.
|
|
628
|
+
* @param text - Text to display
|
|
629
|
+
* @param delayMs - Delay between characters (default 30ms)
|
|
630
|
+
*/
|
|
631
|
+
export async function typewriter(text: string, delayMs = 30): Promise<void> {
|
|
632
|
+
if (!isTTY()) {
|
|
633
|
+
console.log(text);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
for (const char of text) {
|
|
637
|
+
process.stdout.write(char);
|
|
638
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
639
|
+
}
|
|
640
|
+
process.stdout.write('\n');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Progressive reveal: prints lines one at a time with a delay.
|
|
645
|
+
* Falls back to printing all lines at once when not a TTY.
|
|
646
|
+
* @param lines - Lines to reveal progressively
|
|
647
|
+
* @param delayMs - Delay between lines (default 50ms)
|
|
648
|
+
*/
|
|
649
|
+
export async function progressiveReveal(lines: string[], delayMs = 50): Promise<void> {
|
|
650
|
+
if (!isTTY()) {
|
|
651
|
+
for (const line of lines) {
|
|
652
|
+
console.log(line);
|
|
653
|
+
}
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
for (const line of lines) {
|
|
657
|
+
console.log(line);
|
|
658
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ============================================================================
|
|
663
|
+
// TERMINAL BELL
|
|
664
|
+
// ============================================================================
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Ring the terminal bell to notify the user.
|
|
668
|
+
* No-op when not a TTY (piped output, CI, etc.).
|
|
669
|
+
*/
|
|
670
|
+
export function terminalBell(): void {
|
|
671
|
+
if (isTTY()) {
|
|
672
|
+
process.stdout.write('\x07');
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ============================================================================
|
|
677
|
+
// ENHANCED SPINNERS
|
|
678
|
+
// ============================================================================
|
|
679
|
+
|
|
680
|
+
/** Spinner variant presets */
|
|
681
|
+
export type SpinnerVariant = 'donut' | 'sprinkle' | 'minimal';
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Create a themed spinner with a variant style.
|
|
685
|
+
* @param text - Spinner message
|
|
686
|
+
* @param variant - Visual style: 'donut' (default), 'sprinkle', 'minimal'
|
|
687
|
+
*/
|
|
688
|
+
export function createThemedSpinner(text: string, variant: SpinnerVariant = 'donut'): Ora {
|
|
689
|
+
const spinnerConfig: Record<SpinnerVariant, { interval: number; frames: string[] }> = {
|
|
690
|
+
donut: {
|
|
691
|
+
interval: 80,
|
|
692
|
+
frames: Array(8)
|
|
693
|
+
.fill(emoji.donut)
|
|
694
|
+
.map((d: string, i: number) => (i % 2 === 0 ? colors.highlight(d) : colors.muted(d))),
|
|
695
|
+
},
|
|
696
|
+
sprinkle: {
|
|
697
|
+
interval: 120,
|
|
698
|
+
frames: ['🍩', '🍪', '🧁', '🍰', '🎂', '🍰', '🧁', '🍪'],
|
|
699
|
+
},
|
|
700
|
+
minimal: {
|
|
701
|
+
interval: 100,
|
|
702
|
+
frames: ['·', '•', '●', '•'],
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
return ora({
|
|
707
|
+
text,
|
|
708
|
+
color: 'yellow',
|
|
709
|
+
prefixText: INDENT,
|
|
710
|
+
spinner: spinnerConfig[variant],
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ============================================================================
|
|
715
|
+
// CLEAR SCREEN
|
|
716
|
+
// ============================================================================
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Clear the terminal screen.
|
|
720
|
+
* No-op when not a TTY (piped output, CI, etc.).
|
|
721
|
+
*/
|
|
722
|
+
export function clearScreen(): void {
|
|
723
|
+
if (isTTY()) {
|
|
724
|
+
process.stdout.write('\x1B[2J\x1B[0f');
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ============================================================================
|
|
729
|
+
// PROGRESS BAR
|
|
730
|
+
// ============================================================================
|
|
731
|
+
|
|
732
|
+
export interface ProgressBarOptions {
|
|
733
|
+
width?: number;
|
|
734
|
+
filled?: string;
|
|
735
|
+
empty?: string;
|
|
736
|
+
showPercent?: boolean;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function progressBar(done: number, total: number, options: ProgressBarOptions = {}): string {
|
|
740
|
+
const { width = 20, filled = '\u2588', empty = '\u2591', showPercent = true } = options;
|
|
741
|
+
if (total === 0 || width <= 0) return colors.muted('\u2500'.repeat(Math.max(0, width)));
|
|
742
|
+
const filledCount = Math.round((done / total) * width);
|
|
743
|
+
const emptyCount = width - filledCount;
|
|
744
|
+
const percent = Math.round((done / total) * 100);
|
|
745
|
+
const bar = colors.success(filled.repeat(filledCount)) + colors.muted(empty.repeat(emptyCount));
|
|
746
|
+
if (!showPercent) return bar;
|
|
747
|
+
const label = percent === 100 ? colors.success(`${String(percent)}%`) : colors.muted(`${String(percent)}%`);
|
|
748
|
+
return `${bar} ${label}`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ============================================================================
|
|
752
|
+
// TABLE RENDERER
|
|
753
|
+
// ============================================================================
|
|
754
|
+
|
|
755
|
+
export interface TableColumn {
|
|
756
|
+
header: string;
|
|
757
|
+
align?: 'left' | 'right';
|
|
758
|
+
color?: ColorFn;
|
|
759
|
+
minWidth?: number;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export interface TableOptions {
|
|
763
|
+
style?: BoxStyle;
|
|
764
|
+
indent?: number;
|
|
765
|
+
colorFn?: ColorFn;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export function renderTable(columns: TableColumn[], rows: string[][], options: TableOptions = {}): string {
|
|
769
|
+
const { style = 'rounded', indent = 2, colorFn = colors.muted } = options;
|
|
770
|
+
const chars = boxChars[style];
|
|
771
|
+
const pad = ' '.repeat(indent);
|
|
772
|
+
|
|
773
|
+
// Calculate column widths (ANSI-safe)
|
|
774
|
+
const colWidths = columns.map((col, i) => {
|
|
775
|
+
const headerWidth = col.header.length;
|
|
776
|
+
const dataWidth = Math.max(0, ...rows.map((row) => stripAnsi(row[i] ?? '').length));
|
|
777
|
+
return Math.max(headerWidth, dataWidth, col.minWidth ?? 0);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
const result: string[] = [];
|
|
781
|
+
|
|
782
|
+
// Top border
|
|
783
|
+
const topLine = colWidths.map((w) => chars.horizontal.repeat(w + 2)).join(chars.teeDown);
|
|
784
|
+
result.push(pad + colorFn(chars.topLeft + topLine + chars.topRight));
|
|
785
|
+
|
|
786
|
+
// Header row
|
|
787
|
+
const headerCells = columns.map((col, i) => {
|
|
788
|
+
const w = colWidths[i] ?? 0;
|
|
789
|
+
return ' ' + colors.highlight(col.header.padEnd(w)) + ' ';
|
|
790
|
+
});
|
|
791
|
+
result.push(pad + colorFn(chars.vertical) + headerCells.join(colorFn(chars.vertical)) + colorFn(chars.vertical));
|
|
792
|
+
|
|
793
|
+
// Header separator
|
|
794
|
+
const sepLine = colWidths.map((w) => chars.horizontal.repeat(w + 2)).join(chars.cross);
|
|
795
|
+
result.push(pad + colorFn(chars.teeRight + sepLine + chars.teeLeft));
|
|
796
|
+
|
|
797
|
+
// Data rows
|
|
798
|
+
for (const row of rows) {
|
|
799
|
+
const cells = columns.map((col, i) => {
|
|
800
|
+
const w = colWidths[i] ?? 0;
|
|
801
|
+
const cell = row[i] ?? '';
|
|
802
|
+
const visibleLen = stripAnsi(cell).length;
|
|
803
|
+
const padding = Math.max(0, w - visibleLen);
|
|
804
|
+
const coloredCell = col.color ? col.color(cell) : cell;
|
|
805
|
+
if (col.align === 'right') {
|
|
806
|
+
return ' ' + ' '.repeat(padding) + coloredCell + ' ';
|
|
807
|
+
}
|
|
808
|
+
return ' ' + coloredCell + ' '.repeat(padding) + ' ';
|
|
809
|
+
});
|
|
810
|
+
result.push(pad + colorFn(chars.vertical) + cells.join(colorFn(chars.vertical)) + colorFn(chars.vertical));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Bottom border
|
|
814
|
+
const bottomLine = colWidths.map((w) => chars.horizontal.repeat(w + 2)).join(chars.teeUp);
|
|
815
|
+
result.push(pad + colorFn(chars.bottomLeft + bottomLine + chars.bottomRight));
|
|
816
|
+
|
|
817
|
+
return result.join('\n');
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// ============================================================================
|
|
821
|
+
// COLUMN LAYOUT
|
|
822
|
+
// ============================================================================
|
|
823
|
+
|
|
824
|
+
export interface ColumnOptions {
|
|
825
|
+
gap?: number;
|
|
826
|
+
minWidth?: number;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export function renderColumns(blocks: string[][], options: ColumnOptions = {}): string {
|
|
830
|
+
const { gap = 4, minWidth = 20 } = options;
|
|
831
|
+
const colCount = blocks.length;
|
|
832
|
+
if (colCount === 0) return '';
|
|
833
|
+
if (colCount === 1) return (blocks[0] ?? []).join('\n');
|
|
834
|
+
|
|
835
|
+
// Calculate width of each block
|
|
836
|
+
const widths = blocks.map((lines) => Math.max(minWidth, ...lines.map((l) => stripAnsi(l).length)));
|
|
837
|
+
|
|
838
|
+
// Find max line count
|
|
839
|
+
const maxLines = Math.max(...blocks.map((b) => b.length));
|
|
840
|
+
const gapStr = ' '.repeat(gap);
|
|
841
|
+
|
|
842
|
+
const result: string[] = [];
|
|
843
|
+
for (let i = 0; i < maxLines; i++) {
|
|
844
|
+
const parts = blocks.map((block, colIdx) => {
|
|
845
|
+
const line = block[i] ?? '';
|
|
846
|
+
const w = widths[colIdx] ?? minWidth;
|
|
847
|
+
const visibleLen = stripAnsi(line).length;
|
|
848
|
+
return line + ' '.repeat(Math.max(0, w - visibleLen));
|
|
849
|
+
});
|
|
850
|
+
result.push(parts.join(gapStr));
|
|
851
|
+
}
|
|
852
|
+
return result.join('\n');
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ============================================================================
|
|
856
|
+
// PROGRESS SUMMARY
|
|
857
|
+
// ============================================================================
|
|
858
|
+
|
|
859
|
+
export interface ProgressSummaryLabels {
|
|
860
|
+
done?: string;
|
|
861
|
+
remaining?: string;
|
|
862
|
+
title?: string;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export function renderProgressSummary(done: number, total: number, labels: ProgressSummaryLabels = {}): string {
|
|
866
|
+
const { done: doneLabel = 'done', remaining: remainingLabel = 'remaining', title } = labels;
|
|
867
|
+
const remaining = total - done;
|
|
868
|
+
const bar = progressBar(done, total);
|
|
869
|
+
const summary = `${colors.success(String(done))} ${colors.muted(doneLabel)}, ${colors.muted(String(remaining))} ${colors.muted(remainingLabel)}`;
|
|
870
|
+
const prefix = title ? `${colors.highlight(title)} ` : '';
|
|
871
|
+
return `${prefix}${bar} ${summary}`;
|
|
872
|
+
}
|