@lewishowles/cli-style 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/LICENSE +21 -0
- package/README.md +153 -0
- package/adapters/bash/cli-style.sh +50 -0
- package/adapters/python/cli_style.py +104 -0
- package/bin/cli-style.js +82 -0
- package/examples/diagnostic.mjs +30 -0
- package/examples/diagnostic.py +38 -0
- package/examples/diagnostic.sh +29 -0
- package/examples/gallery.mjs +18 -0
- package/package.json +42 -0
- package/src/capability/resolve-profile.js +69 -0
- package/src/capability/terminal.js +105 -0
- package/src/cli/gallery-command.js +238 -0
- package/src/cli/help.js +35 -0
- package/src/cli/render-command.js +283 -0
- package/src/create-cli-style.js +53 -0
- package/src/formatters/ansi.js +154 -0
- package/src/gallery/render-gallery.js +758 -0
- package/src/index.js +52 -0
- package/src/patterns/agent-transcript.js +180 -0
- package/src/patterns/audit-finding.js +107 -0
- package/src/patterns/command-result.js +91 -0
- package/src/patterns/compact-data-table.js +67 -0
- package/src/patterns/confirmation-result.js +88 -0
- package/src/patterns/diagnostic-report.js +144 -0
- package/src/patterns/helpers.js +106 -0
- package/src/patterns/next-step-block.js +118 -0
- package/src/patterns/task-summary.js +59 -0
- package/src/primitives/bar-chart.js +131 -0
- package/src/primitives/chip.js +33 -0
- package/src/primitives/divider.js +64 -0
- package/src/primitives/feedback.js +83 -0
- package/src/primitives/panel.js +100 -0
- package/src/primitives/progress-bar.js +121 -0
- package/src/primitives/row.js +80 -0
- package/src/primitives/status.js +39 -0
- package/src/primitives/step-progress.js +184 -0
- package/src/primitives/table.js +239 -0
- package/src/profiles/profiles.js +24 -0
- package/src/theme/colours.js +141 -0
- package/src/theme/results.js +98 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { profiles } from "../profiles/profiles.js";
|
|
2
|
+
import { status } from "../primitives/status.js";
|
|
3
|
+
import { resultTypes } from "../theme/results.js";
|
|
4
|
+
import {
|
|
5
|
+
formatLabel,
|
|
6
|
+
isNonEmptyString,
|
|
7
|
+
isRecord,
|
|
8
|
+
renderSection,
|
|
9
|
+
renderTitle,
|
|
10
|
+
} from "./helpers.js";
|
|
11
|
+
|
|
12
|
+
// Diagnostic reports use a stable title when the caller does not provide one.
|
|
13
|
+
const defaultTitle = "Diagnostic report";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render check summaries, findings, skipped checks, and next actions.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} report
|
|
19
|
+
* Structured diagnostic report.
|
|
20
|
+
* @param {object[]} report.checks
|
|
21
|
+
* Checks with a name, result, and optional detail.
|
|
22
|
+
* @param {object[]} report.findings
|
|
23
|
+
* Findings with a result and message.
|
|
24
|
+
* @param {string[]} report.nextActions
|
|
25
|
+
* Ordered follow-up actions.
|
|
26
|
+
* @param {object[]} report.skippedChecks
|
|
27
|
+
* Skipped checks with a name and optional reason.
|
|
28
|
+
* @param {string} report.title
|
|
29
|
+
* Report title.
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* Rendering options.
|
|
32
|
+
* @param {string} options.profile
|
|
33
|
+
* Active output profile.
|
|
34
|
+
* @returns {object|string}
|
|
35
|
+
* Structured JSON input or rendered diagnostic text.
|
|
36
|
+
*/
|
|
37
|
+
export function diagnosticReport(report, options = {}) {
|
|
38
|
+
if (options.profile === profiles.JSON) {
|
|
39
|
+
return report;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!isRecord(report)) {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const sections = [
|
|
47
|
+
renderChecks(report.checks, options),
|
|
48
|
+
renderFindings(report.findings, options),
|
|
49
|
+
renderSkippedChecks(report.skippedChecks, options),
|
|
50
|
+
renderNextActions(report.nextActions, options),
|
|
51
|
+
].filter((section) => section !== "");
|
|
52
|
+
const title = typeof report.title === "string" && report.title !== ""
|
|
53
|
+
? report.title
|
|
54
|
+
: defaultTitle;
|
|
55
|
+
return [renderTitle(title, options), ...sections].join("\n\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render completed check summaries.
|
|
60
|
+
*
|
|
61
|
+
* @param {object[]} checks
|
|
62
|
+
* Checks to render.
|
|
63
|
+
* @param {object} options
|
|
64
|
+
* Rendering options.
|
|
65
|
+
* @returns {string}
|
|
66
|
+
* Rendered checks section.
|
|
67
|
+
*/
|
|
68
|
+
function renderChecks(checks, options) {
|
|
69
|
+
const validChecks = Array.isArray(checks)
|
|
70
|
+
? checks.filter((check) => isRecord(check) && isNonEmptyString(check.name))
|
|
71
|
+
: [];
|
|
72
|
+
const lines = validChecks.map((check) => status(
|
|
73
|
+
check.result,
|
|
74
|
+
formatLabel(check.name, check.detail, options),
|
|
75
|
+
options,
|
|
76
|
+
));
|
|
77
|
+
|
|
78
|
+
return renderSection("Checks", lines, options);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Render diagnostic findings.
|
|
83
|
+
*
|
|
84
|
+
* @param {object[]} findings
|
|
85
|
+
* Findings to render.
|
|
86
|
+
* @param {object} options
|
|
87
|
+
* Rendering options.
|
|
88
|
+
* @returns {string}
|
|
89
|
+
* Rendered findings section.
|
|
90
|
+
*/
|
|
91
|
+
function renderFindings(findings, options) {
|
|
92
|
+
const validFindings = Array.isArray(findings)
|
|
93
|
+
? findings.filter((finding) => isRecord(finding) && isNonEmptyString(finding.message))
|
|
94
|
+
: [];
|
|
95
|
+
const lines = validFindings.map((finding) => status(
|
|
96
|
+
finding.result,
|
|
97
|
+
finding.message,
|
|
98
|
+
options,
|
|
99
|
+
));
|
|
100
|
+
|
|
101
|
+
return renderSection("Findings", lines, options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Render skipped checks.
|
|
106
|
+
*
|
|
107
|
+
* @param {object[]} skippedChecks
|
|
108
|
+
* Skipped checks to render.
|
|
109
|
+
* @param {object} options
|
|
110
|
+
* Rendering options.
|
|
111
|
+
* @returns {string}
|
|
112
|
+
* Rendered skipped-check section.
|
|
113
|
+
*/
|
|
114
|
+
function renderSkippedChecks(skippedChecks, options) {
|
|
115
|
+
const validChecks = Array.isArray(skippedChecks)
|
|
116
|
+
? skippedChecks.filter((check) => isRecord(check) && isNonEmptyString(check.name))
|
|
117
|
+
: [];
|
|
118
|
+
const lines = validChecks.map((check) => status(
|
|
119
|
+
resultTypes.SKIPPED,
|
|
120
|
+
formatLabel(check.name, check.reason, options),
|
|
121
|
+
options,
|
|
122
|
+
));
|
|
123
|
+
|
|
124
|
+
return renderSection("Skipped checks", lines, options);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Render ordered next actions.
|
|
129
|
+
*
|
|
130
|
+
* @param {string[]} nextActions
|
|
131
|
+
* Actions to render.
|
|
132
|
+
* @param {object} options
|
|
133
|
+
* Rendering options.
|
|
134
|
+
* @returns {string}
|
|
135
|
+
* Rendered next-action section.
|
|
136
|
+
*/
|
|
137
|
+
function renderNextActions(nextActions, options) {
|
|
138
|
+
const validActions = Array.isArray(nextActions)
|
|
139
|
+
? nextActions.filter(isNonEmptyString)
|
|
140
|
+
: [];
|
|
141
|
+
const lines = validActions.map((action, index) => `${index + 1}. ${action}`);
|
|
142
|
+
|
|
143
|
+
return renderSection("Next actions", lines, options, false);
|
|
144
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { profiles } from "../profiles/profiles.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Join a primary label and optional detail.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} label
|
|
7
|
+
* Primary label.
|
|
8
|
+
* @param {*} detail
|
|
9
|
+
* Optional detail value.
|
|
10
|
+
* @param {object} options
|
|
11
|
+
* Rendering options.
|
|
12
|
+
* @returns {string}
|
|
13
|
+
* Combined detail text.
|
|
14
|
+
*/
|
|
15
|
+
export function formatLabel(label, detail, options) {
|
|
16
|
+
if (!isNonEmptyString(detail)) {
|
|
17
|
+
return label;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!isNonEmptyString(label)) {
|
|
21
|
+
return detail;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const separator = options.unicode === false ? " - " : " — ";
|
|
25
|
+
|
|
26
|
+
return `${label}${separator}${detail}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check whether a value contains usable text.
|
|
31
|
+
*
|
|
32
|
+
* @param {*} value
|
|
33
|
+
* Value to check.
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
* Whether the value is a non-empty string.
|
|
36
|
+
*/
|
|
37
|
+
export function isNonEmptyString(value) {
|
|
38
|
+
return typeof value === "string" && value !== "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check whether a value is a non-array object.
|
|
43
|
+
*
|
|
44
|
+
* @param {*} value
|
|
45
|
+
* Value to check.
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
* Whether the value is a record.
|
|
48
|
+
*/
|
|
49
|
+
export function isRecord(value) {
|
|
50
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Return usable text entries from an uncertain value.
|
|
55
|
+
*
|
|
56
|
+
* @param {*} values
|
|
57
|
+
* Values to normalise.
|
|
58
|
+
* @returns {string[]}
|
|
59
|
+
* Non-empty strings.
|
|
60
|
+
*/
|
|
61
|
+
export function normaliseStringList(values) {
|
|
62
|
+
return Array.isArray(values) ? values.filter(isNonEmptyString) : [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Render a titled pattern section.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} title
|
|
69
|
+
* Section title.
|
|
70
|
+
* @param {string[]} lines
|
|
71
|
+
* Section content.
|
|
72
|
+
* @param {object} options
|
|
73
|
+
* Rendering options.
|
|
74
|
+
* @param {boolean} listItems
|
|
75
|
+
* Whether agent output should use bullet items.
|
|
76
|
+
* @returns {string}
|
|
77
|
+
* Rendered section.
|
|
78
|
+
*/
|
|
79
|
+
export function renderSection(title, lines, options, listItems = true) {
|
|
80
|
+
if (lines.length === 0) {
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const heading = options.profile === profiles.AGENT
|
|
85
|
+
? `## ${title}`
|
|
86
|
+
: title;
|
|
87
|
+
const content = options.profile === profiles.AGENT && listItems
|
|
88
|
+
? lines.map((line) => `- ${line}`)
|
|
89
|
+
: lines;
|
|
90
|
+
|
|
91
|
+
return [heading, ...content].join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Render a pattern title for the active profile.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} title
|
|
98
|
+
* Pattern title.
|
|
99
|
+
* @param {object} options
|
|
100
|
+
* Rendering options.
|
|
101
|
+
* @returns {string}
|
|
102
|
+
* Rendered title.
|
|
103
|
+
*/
|
|
104
|
+
export function renderTitle(title, options) {
|
|
105
|
+
return options.profile === profiles.AGENT ? `# ${title}` : title;
|
|
106
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { profiles } from "../profiles/profiles.js";
|
|
2
|
+
import { row } from "../primitives/row.js";
|
|
3
|
+
import {
|
|
4
|
+
isNonEmptyString,
|
|
5
|
+
isRecord,
|
|
6
|
+
normaliseStringList,
|
|
7
|
+
renderSection,
|
|
8
|
+
renderTitle,
|
|
9
|
+
} from "./helpers.js";
|
|
10
|
+
|
|
11
|
+
// Next-step blocks use a stable title when the caller does not provide one.
|
|
12
|
+
const defaultTitle = "Next step";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render the first concrete follow-up action and supporting context.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} nextStep
|
|
18
|
+
* Structured next-step block.
|
|
19
|
+
* @param {string[]} nextStep.alternatives
|
|
20
|
+
* Secondary actions.
|
|
21
|
+
* @param {string[]} nextStep.commands
|
|
22
|
+
* Commands needed for the action.
|
|
23
|
+
* @param {string} nextStep.next
|
|
24
|
+
* Primary next action.
|
|
25
|
+
* @param {string} nextStep.reason
|
|
26
|
+
* Why the action is next.
|
|
27
|
+
* @param {string} nextStep.title
|
|
28
|
+
* Pattern title.
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* Rendering options.
|
|
31
|
+
* @returns {object|string}
|
|
32
|
+
* Structured JSON input or rendered next-step block.
|
|
33
|
+
*/
|
|
34
|
+
export function nextStepBlock(nextStep, options = {}) {
|
|
35
|
+
if (options.profile === profiles.JSON) {
|
|
36
|
+
return nextStep;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!isRecord(nextStep)) {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const title = isNonEmptyString(nextStep.title) ? nextStep.title : defaultTitle;
|
|
44
|
+
const next = renderNext(nextStep.next, options);
|
|
45
|
+
const reason = isNonEmptyString(nextStep.reason)
|
|
46
|
+
? renderSection("Why", [nextStep.reason], options, false)
|
|
47
|
+
: "";
|
|
48
|
+
const commands = renderCommands(nextStep.commands, options);
|
|
49
|
+
const alternatives = normaliseStringList(nextStep.alternatives)
|
|
50
|
+
.map((alternative) => `- ${alternative}`);
|
|
51
|
+
const sections = [
|
|
52
|
+
next,
|
|
53
|
+
reason,
|
|
54
|
+
commands,
|
|
55
|
+
renderSection("Alternatives", alternatives, options, false),
|
|
56
|
+
].filter((section) => section !== "");
|
|
57
|
+
|
|
58
|
+
return [renderTitle(title, options), ...sections].join("\n\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render the primary next action.
|
|
63
|
+
*
|
|
64
|
+
* @param {*} next
|
|
65
|
+
* Primary next action.
|
|
66
|
+
* @param {object} options
|
|
67
|
+
* Rendering options.
|
|
68
|
+
* @returns {string}
|
|
69
|
+
* Rendered next action.
|
|
70
|
+
*/
|
|
71
|
+
function renderNext(next, options) {
|
|
72
|
+
if (!isNonEmptyString(next)) {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (options.profile === profiles.AGENT) {
|
|
77
|
+
return renderSection("Next", [next], options, false);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return row("Next", next, {
|
|
81
|
+
...options,
|
|
82
|
+
labelWidth: 4,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render commands as shell text.
|
|
88
|
+
*
|
|
89
|
+
* @param {*} commands
|
|
90
|
+
* Commands to render.
|
|
91
|
+
* @param {object} options
|
|
92
|
+
* Rendering options.
|
|
93
|
+
* @returns {string}
|
|
94
|
+
* Rendered commands.
|
|
95
|
+
*/
|
|
96
|
+
function renderCommands(commands, options) {
|
|
97
|
+
const validCommands = normaliseStringList(commands);
|
|
98
|
+
|
|
99
|
+
if (validCommands.length === 0) {
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (options.profile === profiles.AGENT) {
|
|
104
|
+
return [
|
|
105
|
+
"## Commands",
|
|
106
|
+
"```sh",
|
|
107
|
+
...validCommands,
|
|
108
|
+
"```",
|
|
109
|
+
].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return renderSection(
|
|
113
|
+
"Commands",
|
|
114
|
+
validCommands.map((command) => `$ ${command}`),
|
|
115
|
+
options,
|
|
116
|
+
false,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { profiles } from "../profiles/profiles.js";
|
|
2
|
+
import { status } from "../primitives/status.js";
|
|
3
|
+
import { resultTypes } from "../theme/results.js";
|
|
4
|
+
import {
|
|
5
|
+
formatLabel,
|
|
6
|
+
isNonEmptyString,
|
|
7
|
+
isRecord,
|
|
8
|
+
normaliseStringList,
|
|
9
|
+
renderSection,
|
|
10
|
+
renderTitle,
|
|
11
|
+
} from "./helpers.js";
|
|
12
|
+
|
|
13
|
+
// Task summaries use a stable title when the caller does not provide one.
|
|
14
|
+
const defaultTitle = "Task summary";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Render completed and remaining work for one task.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} summary
|
|
20
|
+
* Structured task summary.
|
|
21
|
+
* @param {string[]} summary.completed
|
|
22
|
+
* Completed work.
|
|
23
|
+
* @param {string[]} summary.remaining
|
|
24
|
+
* Remaining work.
|
|
25
|
+
* @param {string} summary.result
|
|
26
|
+
* Semantic result state.
|
|
27
|
+
* @param {string} summary.summary
|
|
28
|
+
* Short supporting summary.
|
|
29
|
+
* @param {string} summary.task
|
|
30
|
+
* Task name.
|
|
31
|
+
* @param {string} summary.title
|
|
32
|
+
* Pattern title.
|
|
33
|
+
* @param {object} options
|
|
34
|
+
* Rendering options.
|
|
35
|
+
* @returns {object|string}
|
|
36
|
+
* Structured JSON input or rendered task summary.
|
|
37
|
+
*/
|
|
38
|
+
export function taskSummary(summary, options = {}) {
|
|
39
|
+
if (options.profile === profiles.JSON) {
|
|
40
|
+
return summary;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!isRecord(summary)) {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const title = isNonEmptyString(summary.title) ? summary.title : defaultTitle;
|
|
48
|
+
const task = isNonEmptyString(summary.task) ? summary.task : "";
|
|
49
|
+
const detail = formatLabel(task, summary.summary, options);
|
|
50
|
+
const completed = normaliseStringList(summary.completed).map((item) => `- ${item}`);
|
|
51
|
+
const remaining = normaliseStringList(summary.remaining).map((item) => `- ${item}`);
|
|
52
|
+
const sections = [
|
|
53
|
+
status(summary.result ?? resultTypes.UNKNOWN, detail, options),
|
|
54
|
+
renderSection("Completed", completed, options, false),
|
|
55
|
+
renderSection("Remaining", remaining, options, false),
|
|
56
|
+
].filter((section) => section !== "");
|
|
57
|
+
|
|
58
|
+
return [renderTitle(title, options), ...sections].join("\n\n");
|
|
59
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { foreground } from "../formatters/ansi.js";
|
|
2
|
+
import { chartColours } from "../theme/colours.js";
|
|
3
|
+
|
|
4
|
+
// Chart bars use the same compact default width as progress bars.
|
|
5
|
+
const defaultWidth = 20;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Render multiple labelled values against a shared scale.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} options
|
|
11
|
+
* Rendering options.
|
|
12
|
+
* @param {number} options.barWidth
|
|
13
|
+
* Number of characters used by the largest bar.
|
|
14
|
+
* @param {boolean} options.colour
|
|
15
|
+
* Whether ANSI colour should be applied.
|
|
16
|
+
* @param {object[]} options.rows
|
|
17
|
+
* Labelled numeric values to render.
|
|
18
|
+
* @param {boolean} options.unicode
|
|
19
|
+
* Whether Unicode bar characters should be used.
|
|
20
|
+
* @returns {string}
|
|
21
|
+
* Rendered bar chart.
|
|
22
|
+
*/
|
|
23
|
+
export function barChart(options = {}) {
|
|
24
|
+
const rows = normaliseRows(options.rows);
|
|
25
|
+
|
|
26
|
+
if (rows.length === 0) {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const labelWidth = Math.max(...rows.map((row) => row.label.length));
|
|
31
|
+
const maximum = Math.max(...rows.map((row) => Math.abs(row.value)), 1);
|
|
32
|
+
const width = normaliseWidth(options.barWidth);
|
|
33
|
+
|
|
34
|
+
return rows
|
|
35
|
+
.map((row) => renderRow(row, labelWidth, maximum, width, options))
|
|
36
|
+
.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Return rows with usable labels and values.
|
|
41
|
+
*
|
|
42
|
+
* @param {object[]} rows
|
|
43
|
+
* Requested chart rows.
|
|
44
|
+
* @returns {object[]}
|
|
45
|
+
* Valid chart rows.
|
|
46
|
+
*/
|
|
47
|
+
function normaliseRows(rows) {
|
|
48
|
+
if (!Array.isArray(rows)) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return rows.filter((row) => (
|
|
53
|
+
row !== null
|
|
54
|
+
&& typeof row === "object"
|
|
55
|
+
&& typeof row.label === "string"
|
|
56
|
+
&& row.label !== ""
|
|
57
|
+
&& Number.isFinite(row.value)
|
|
58
|
+
));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Return a usable integer bar width.
|
|
63
|
+
*
|
|
64
|
+
* @param {number} width
|
|
65
|
+
* Requested bar width.
|
|
66
|
+
* @returns {number}
|
|
67
|
+
* Positive integer width.
|
|
68
|
+
*/
|
|
69
|
+
function normaliseWidth(width) {
|
|
70
|
+
if (!Number.isFinite(width)) {
|
|
71
|
+
return defaultWidth;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Math.max(Math.floor(width), 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Render one aligned chart row.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} row
|
|
81
|
+
* Chart row.
|
|
82
|
+
* @param {number} labelWidth
|
|
83
|
+
* Shared label width.
|
|
84
|
+
* @param {number} maximum
|
|
85
|
+
* Largest absolute chart value.
|
|
86
|
+
* @param {number} width
|
|
87
|
+
* Maximum bar width.
|
|
88
|
+
* @param {object} options
|
|
89
|
+
* Rendering options.
|
|
90
|
+
* @returns {string}
|
|
91
|
+
* Rendered chart row.
|
|
92
|
+
*/
|
|
93
|
+
function renderRow(row, labelWidth, maximum, width, options) {
|
|
94
|
+
const barWidth = Math.round((Math.abs(row.value) / maximum) * width);
|
|
95
|
+
const symbol = options.unicode === false ? "#" : "█";
|
|
96
|
+
const bar = symbol.repeat(barWidth).padEnd(width, " ");
|
|
97
|
+
const label = row.label.padEnd(labelWidth, " ");
|
|
98
|
+
|
|
99
|
+
if (options.colour !== true) {
|
|
100
|
+
return `${label} ${bar} ${row.value}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const renderedLabel = foreground(label, chartColours.label, options);
|
|
104
|
+
const renderedBar = barWidth === 0
|
|
105
|
+
? ""
|
|
106
|
+
: foreground(bar.slice(0, barWidth), barColour(row), options);
|
|
107
|
+
const barPadding = bar.slice(barWidth);
|
|
108
|
+
const renderedValue = foreground(String(row.value), chartColours.value, options);
|
|
109
|
+
|
|
110
|
+
return `${renderedLabel} ${renderedBar}${barPadding} ${renderedValue}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Return the colour for a chart row.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} row
|
|
117
|
+
* Chart row.
|
|
118
|
+
* @returns {string}
|
|
119
|
+
* Colour token or hex value.
|
|
120
|
+
*/
|
|
121
|
+
function barColour(row) {
|
|
122
|
+
if (row.value < 0) {
|
|
123
|
+
return chartColours.barNegative;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (row.tone === "warning") {
|
|
127
|
+
return chartColours.barWarning;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return chartColours.barPositive;
|
|
131
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { background, foreground, style } from "../formatters/ansi.js";
|
|
2
|
+
import { chipColours } from "../theme/colours.js";
|
|
3
|
+
|
|
4
|
+
// Chip style used when the requested tone is not configured.
|
|
5
|
+
const fallbackTone = "neutral";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Render a compact label chip.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} label
|
|
11
|
+
* Label text to render.
|
|
12
|
+
* @param {string} tone
|
|
13
|
+
* Chip tone to use.
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* Rendering options.
|
|
16
|
+
* @param {boolean} options.colour
|
|
17
|
+
* Whether ANSI colour should be applied.
|
|
18
|
+
* @returns {string}
|
|
19
|
+
* Rendered chip.
|
|
20
|
+
*/
|
|
21
|
+
export function chip(label, tone = fallbackTone, options = {}) {
|
|
22
|
+
const token = chipColours[tone] ?? chipColours[fallbackTone];
|
|
23
|
+
|
|
24
|
+
if (options.colour !== true) {
|
|
25
|
+
return `[${label}]`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const paddedLabel = ` ${label} `;
|
|
29
|
+
const colouredLabel = foreground(paddedLabel, token.foreground, options);
|
|
30
|
+
const boldLabel = style(colouredLabel, "bold", options);
|
|
31
|
+
|
|
32
|
+
return background(boldLabel, token.background, options);
|
|
33
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { foreground } from "../formatters/ansi.js";
|
|
2
|
+
|
|
3
|
+
// Dividers use enough width to read as a section break in compact output.
|
|
4
|
+
const defaultWidth = 40;
|
|
5
|
+
|
|
6
|
+
// Hyphen keeps the divider stable in plain and ASCII-only terminals.
|
|
7
|
+
const defaultCharacter = "-";
|
|
8
|
+
|
|
9
|
+
// Divider colour should expose the border token in diagnostic output.
|
|
10
|
+
const defaultColour = "border";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render a horizontal divider.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} options
|
|
16
|
+
* Rendering options.
|
|
17
|
+
* @param {string} options.character
|
|
18
|
+
* Character to repeat.
|
|
19
|
+
* @param {boolean} options.colour
|
|
20
|
+
* Whether ANSI colour should be applied.
|
|
21
|
+
* @param {string} options.dividerColour
|
|
22
|
+
* Colour token for the divider.
|
|
23
|
+
* @param {number} options.dividerWidth
|
|
24
|
+
* Divider width.
|
|
25
|
+
* @param {string} options.label
|
|
26
|
+
* Optional label to render before the divider line.
|
|
27
|
+
* @param {string} options.labelColour
|
|
28
|
+
* Colour token for the label.
|
|
29
|
+
* @returns {string}
|
|
30
|
+
* Rendered divider.
|
|
31
|
+
*/
|
|
32
|
+
export function divider(options = {}) {
|
|
33
|
+
const character = options.character ?? defaultCharacter;
|
|
34
|
+
const label = options.label ?? "";
|
|
35
|
+
const width = options.dividerWidth ?? defaultWidth;
|
|
36
|
+
const lineWidth = Math.max(width - label.length - labelGap(label), 0);
|
|
37
|
+
const line = character.repeat(lineWidth);
|
|
38
|
+
const output = label === "" ? line : `${label} ${line}`;
|
|
39
|
+
|
|
40
|
+
if (options.colour !== true) {
|
|
41
|
+
return output;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (label === "") {
|
|
45
|
+
return foreground(line, options.dividerColour ?? defaultColour, options);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const renderedLabel = foreground(label, options.labelColour ?? "info", options);
|
|
49
|
+
const renderedLine = foreground(line, options.dividerColour ?? defaultColour, options);
|
|
50
|
+
|
|
51
|
+
return `${renderedLabel} ${renderedLine}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Return the label spacing used inside the requested divider width.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} label
|
|
58
|
+
* Divider label.
|
|
59
|
+
* @returns {number}
|
|
60
|
+
* Width reserved for label gap.
|
|
61
|
+
*/
|
|
62
|
+
function labelGap(label) {
|
|
63
|
+
return label === "" ? 0 : 1;
|
|
64
|
+
}
|