@lewishowles/cli-style 0.9.0 → 0.11.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.
@@ -0,0 +1,333 @@
1
+ import { background, foreground, stripAnsi } from "../formatters/ansi.js";
2
+ import { profiles } from "../profiles/profiles.js";
3
+ import { diffColours } from "../theme/colours.js";
4
+ import { isNonEmptyString, isRecord, renderTitle } from "./helpers.js";
5
+
6
+ // Diff line types map stable prefixes and semantic tones for text profiles.
7
+ const diffLineTypes = {
8
+ added: {
9
+ prefix: "+ ",
10
+ tone: "success",
11
+ },
12
+ context: {
13
+ prefix: " ",
14
+ tone: "muted",
15
+ },
16
+ header: {
17
+ prefix: "@@ ",
18
+ tone: "info",
19
+ },
20
+ removed: {
21
+ prefix: "- ",
22
+ tone: "danger",
23
+ },
24
+ };
25
+
26
+ // Full-row fills apply only to colour-enabled terminal text profiles.
27
+ const backgroundProfiles = new Set([profiles.DIAGNOSTIC, profiles.HUMAN]);
28
+
29
+ /**
30
+ * Render a structured diff block for human and agent output.
31
+ *
32
+ * @param {object} diff
33
+ * Structured diff input.
34
+ * @param {object[]} diff.lines
35
+ * Structured diff lines.
36
+ * @param {number} diff.lines[].oldLineNumber
37
+ * Optional caller-supplied old line number.
38
+ * @param {number} diff.lines[].newLineNumber
39
+ * Optional caller-supplied new line number.
40
+ * @param {string} diff.path
41
+ * Optional affected path.
42
+ * @param {string} diff.title
43
+ * Optional block title.
44
+ * @param {object} options
45
+ * Rendering options.
46
+ * @returns {object|string}
47
+ * Structured JSON input or rendered diff block.
48
+ */
49
+ export function diffBlock(diff, options = {}) {
50
+ if (options.profile === profiles.JSON) {
51
+ return diff;
52
+ }
53
+
54
+ if (!isRecord(diff)) {
55
+ return "";
56
+ }
57
+
58
+ const lines = normaliseLines(diff.lines);
59
+
60
+ if (lines.length === 0) {
61
+ return "";
62
+ }
63
+
64
+ const gutterWidth = getGutterWidth(lines);
65
+ const renderedLines = lines.map((line) => renderLine(line, options, gutterWidth));
66
+ const summary = gutterWidth === undefined ? "" : renderChangeSummary(lines, options);
67
+ const metadata = renderMetadata(diff, options, summary);
68
+
69
+ if (options.profile === profiles.AGENT) {
70
+ const codeFence = ["```diff", renderedLines.join("\n"), "```"].join("\n");
71
+
72
+ return [...metadata, codeFence].join("\n\n");
73
+ }
74
+
75
+ return [...metadata, renderedLines.join("\n")].filter((section) => section !== "").join("\n\n");
76
+ }
77
+
78
+ /**
79
+ * Return structured lines with supported types and text.
80
+ *
81
+ * @param {*} lines
82
+ * Candidate diff lines.
83
+ * @returns {object[]}
84
+ * Valid diff lines.
85
+ */
86
+ function normaliseLines(lines) {
87
+ if (!Array.isArray(lines)) {
88
+ return [];
89
+ }
90
+
91
+ return lines.filter(isValidLine).map((line) => ({
92
+ newLineNumber: normaliseLineNumber(line.newLineNumber),
93
+ oldLineNumber: normaliseLineNumber(line.oldLineNumber),
94
+ text: stripAnsi(line.text),
95
+ type: line.type,
96
+ }));
97
+ }
98
+
99
+ /**
100
+ * Return a valid positive line number or undefined.
101
+ *
102
+ * @param {*} value
103
+ * Candidate line number.
104
+ * @returns {number|undefined}
105
+ * Normalised line number.
106
+ */
107
+ function normaliseLineNumber(value) {
108
+ return Number.isInteger(value) && value > 0 ? value : undefined;
109
+ }
110
+
111
+ /**
112
+ * Return the stable gutter width for a fully numbered block.
113
+ *
114
+ * @param {object[]} lines
115
+ * Normalised diff lines.
116
+ * @returns {number|undefined}
117
+ * Widest line-number width, or undefined when gutters are unavailable.
118
+ */
119
+ function getGutterWidth(lines) {
120
+ const codeLines = lines.filter((line) => line.type !== "header");
121
+
122
+ if (codeLines.length === 0 || !codeLines.every(hasRequiredLineNumbers)) {
123
+ return undefined;
124
+ }
125
+
126
+ return Math.max(
127
+ ...codeLines
128
+ .flatMap(getVisibleLineNumbers)
129
+ .filter((lineNumber) => lineNumber !== undefined)
130
+ .map((lineNumber) => String(lineNumber).length),
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Return line numbers visible for one diff type.
136
+ *
137
+ * @param {object} line
138
+ * Normalised diff line.
139
+ * @returns {number[]}
140
+ * Visible old and new line numbers.
141
+ */
142
+ function getVisibleLineNumbers(line) {
143
+ if (line.type === "added") {
144
+ return [line.newLineNumber];
145
+ }
146
+
147
+ if (line.type === "removed") {
148
+ return [line.oldLineNumber];
149
+ }
150
+
151
+ return [line.oldLineNumber, line.newLineNumber];
152
+ }
153
+
154
+ /**
155
+ * Check whether a line has the numbers required by its diff type.
156
+ *
157
+ * @param {object} line
158
+ * Normalised diff line.
159
+ * @returns {boolean}
160
+ * Whether the line can participate in a numbered gutter.
161
+ */
162
+ function hasRequiredLineNumbers(line) {
163
+ if (line.type === "added") {
164
+ return line.newLineNumber !== undefined;
165
+ }
166
+
167
+ if (line.type === "removed") {
168
+ return line.oldLineNumber !== undefined;
169
+ }
170
+
171
+ return line.oldLineNumber !== undefined && line.newLineNumber !== undefined;
172
+ }
173
+
174
+ /**
175
+ * Check whether a candidate is a supported diff line.
176
+ *
177
+ * @param {*} line
178
+ * Candidate diff line.
179
+ * @returns {boolean}
180
+ * Whether the line can be rendered safely.
181
+ */
182
+ function isValidLine(line) {
183
+ return isRecord(line) && Object.hasOwn(diffLineTypes, line.type) && typeof line.text === "string";
184
+ }
185
+
186
+ /**
187
+ * Render optional diff metadata for the active profile.
188
+ *
189
+ * @param {object} diff
190
+ * Structured diff input.
191
+ * @param {object} options
192
+ * Rendering options.
193
+ * @param {string} summary
194
+ * Optional added and removed line count.
195
+ * @returns {string[]}
196
+ * Rendered metadata sections.
197
+ */
198
+ function renderMetadata(diff, options, summary) {
199
+ const metadata = [];
200
+
201
+ if (isNonEmptyString(diff.title)) {
202
+ const title = stripAnsi(diff.title);
203
+
204
+ metadata.push(renderTitle(formatMetadataValue(title, summary), options));
205
+ }
206
+
207
+ if (isNonEmptyString(diff.path)) {
208
+ const path = stripAnsi(diff.path);
209
+
210
+ metadata.push(`Path: ${formatMetadataValue(path, summary, diff.title)}`);
211
+ } else if (!isNonEmptyString(diff.title) && summary !== "") {
212
+ metadata.push(summary);
213
+ }
214
+
215
+ return metadata;
216
+ }
217
+
218
+ /**
219
+ * Append a change summary to the first available metadata value.
220
+ *
221
+ * @param {string} value
222
+ * Metadata value.
223
+ * @param {string} summary
224
+ * Optional change summary.
225
+ * @param {*} title
226
+ * Title value used to avoid duplicating a summary on the path.
227
+ * @returns {string}
228
+ * Metadata value with optional summary.
229
+ */
230
+ function formatMetadataValue(value, summary, title) {
231
+ return summary !== "" && !isNonEmptyString(title) ? `${value} ${summary}` : value;
232
+ }
233
+
234
+ /**
235
+ * Count added and removed lines already present in the block.
236
+ *
237
+ * @param {object[]} lines
238
+ * Normalised diff lines.
239
+ * @param {object} options
240
+ * Rendering options.
241
+ * @returns {string}
242
+ * Optional change summary.
243
+ */
244
+ function renderChangeSummary(lines, options) {
245
+ const added = lines.filter((line) => line.type === "added").length;
246
+ const removed = lines.filter((line) => line.type === "removed").length;
247
+
248
+ if (added === 0 && removed === 0) {
249
+ return "";
250
+ }
251
+
252
+ const profile = options.profile ?? profiles.HUMAN;
253
+ const addedValue = `+${added}`;
254
+ const removedValue = `-${removed}`;
255
+
256
+ if (options.colour !== true || !backgroundProfiles.has(profile)) {
257
+ return `(${addedValue} ${removedValue})`;
258
+ }
259
+
260
+ return `(${foreground(addedValue, "success", options)} ${foreground(removedValue, "danger", options)})`;
261
+ }
262
+
263
+ /**
264
+ * Render one diff line with a stable semantic prefix.
265
+ *
266
+ * @param {object} line
267
+ * Normalised diff line.
268
+ * @param {object} options
269
+ * Rendering options.
270
+ * @param {number|undefined} gutterWidth
271
+ * Stable gutter width for the block.
272
+ * @returns {string}
273
+ * Rendered diff line.
274
+ */
275
+ function renderLine(line, options, gutterWidth) {
276
+ const lineType = diffLineTypes[line.type];
277
+ const gutter = gutterWidth === undefined ? "" : renderGutter(line, gutterWidth);
278
+ const lineValue = `${gutter}${lineType.prefix}${line.text}`;
279
+ const profile = options.profile ?? profiles.HUMAN;
280
+ const fill = diffColours[line.type];
281
+
282
+ if (options.colour === true && backgroundProfiles.has(profile) && fill !== undefined) {
283
+ return background(foreground(lineValue, fill.foreground, options), fill.background, options);
284
+ }
285
+
286
+ const prefix = foreground(lineType.prefix, lineType.tone, options);
287
+
288
+ return `${gutter}${prefix}${line.text}`;
289
+ }
290
+
291
+ /**
292
+ * Render one stable old/new line-number gutter.
293
+ *
294
+ * @param {object} line
295
+ * Normalised diff line.
296
+ * @param {number} width
297
+ * Widest line-number width in the block.
298
+ * @returns {string}
299
+ * Rendered gutter.
300
+ */
301
+ function renderGutter(line, width) {
302
+ const blank = " ".repeat(width);
303
+ const oldNumber = formatLineNumber(line.oldLineNumber, width);
304
+ const newNumber = formatLineNumber(line.newLineNumber, width);
305
+
306
+ if (line.type === "added") {
307
+ return `${blank} ${newNumber} `;
308
+ }
309
+
310
+ if (line.type === "removed") {
311
+ return `${oldNumber} ${blank} `;
312
+ }
313
+
314
+ if (line.type === "context" && line.oldLineNumber === line.newLineNumber) {
315
+ return `${oldNumber} ${blank} `;
316
+ }
317
+
318
+ return `${oldNumber} ${newNumber} `;
319
+ }
320
+
321
+ /**
322
+ * Pad one line number to the block gutter width.
323
+ *
324
+ * @param {number|undefined} value
325
+ * Line number to format.
326
+ * @param {number} width
327
+ * Gutter width.
328
+ * @returns {string}
329
+ * Right-aligned number or blank placeholder.
330
+ */
331
+ function formatLineNumber(value, width) {
332
+ return value === undefined ? " ".repeat(width) : String(value).padStart(width, " ");
333
+ }
@@ -21,7 +21,7 @@ const fallbackTone = "neutral";
21
21
  export function chip(label, tone = fallbackTone, options = {}) {
22
22
  const token = chipColours[tone] ?? chipColours[fallbackTone];
23
23
 
24
- if (options.colour !== true) {
24
+ if (options.colour !== true || options.theme === "auto") {
25
25
  return `[${label}]`;
26
26
  }
27
27
 
@@ -72,7 +72,12 @@ function renderLine(line, width, isTitle, options) {
72
72
  }
73
73
 
74
74
  const renderedAccent = renderPanelColour(accent, options);
75
- const renderedContent = background(content, panelColours.background, options);
75
+
76
+ const renderedContent = foreground(
77
+ background(content, panelColours.background, options),
78
+ panelColours.body,
79
+ options,
80
+ );
76
81
 
77
82
  return `${renderedAccent}${isTitle ? style(renderedContent, "bold", options) : renderedContent}`;
78
83
  }
@@ -0,0 +1,209 @@
1
+ import { foreground } from "../formatters/ansi.js";
2
+ import { getToneColour } from "../theme/colours.js";
3
+
4
+ // Sparklines balance useful resolution with compact terminal output.
5
+ const defaultWidth = 20;
6
+
7
+ // Success gives an unconfigured trend a familiar positive semantic tone.
8
+ const defaultTone = "success";
9
+
10
+ // Unicode levels show eight graduated values without needing colour.
11
+ const unicodeSymbols = "▁▂▃▄▅▆▇█";
12
+
13
+ // ASCII levels provide the same eight-step visual fallback.
14
+ const asciiSymbols = "._-:=+*#";
15
+
16
+ /**
17
+ * Render a compact trend with stable numeric context.
18
+ *
19
+ * @param {object} options
20
+ * Rendering options.
21
+ * @param {boolean} options.colour
22
+ * Whether ANSI colour should be applied.
23
+ * @param {string} options.label
24
+ * Optional trend label.
25
+ * @param {string} options.tone
26
+ * Semantic colour tone for the trend.
27
+ * @param {boolean} options.unicode
28
+ * Whether graduated Unicode characters should be used.
29
+ * @param {number[]} options.values
30
+ * Finite numeric values in time order.
31
+ * @param {number} options.width
32
+ * Maximum number of plotted samples.
33
+ * @returns {string}
34
+ * Rendered sparkline, or an empty string for unusable values.
35
+ */
36
+ export function sparkline(options = {}) {
37
+ const values = normaliseValues(options.values);
38
+
39
+ if (values.length === 0) {
40
+ return "";
41
+ }
42
+
43
+ const range = resolveRange(values);
44
+ const width = normaliseWidth(options.width);
45
+ const sampledValues = sampleValues(values, width, range);
46
+ const symbols = options.unicode === false ? asciiSymbols : unicodeSymbols;
47
+ const trend = sampledValues.map((value) => renderValue(value, range, symbols)).join("");
48
+ const numericText = `latest=${values.at(-1)} min=${range.minimum} max=${range.maximum}`;
49
+
50
+ const label =
51
+ typeof options.label === "string" && options.label !== "" ? `${options.label}: ` : "";
52
+
53
+ const renderedTrend = renderTrend(trend, options);
54
+
55
+ return `${label}${renderedTrend} ${numericText}`;
56
+ }
57
+
58
+ /**
59
+ * Return finite numeric values when the input is usable.
60
+ *
61
+ * @param {*} values
62
+ * Requested trend values.
63
+ * @returns {number[]}
64
+ * Finite values, or an empty array.
65
+ */
66
+ function normaliseValues(values) {
67
+ if (
68
+ !Array.isArray(values) ||
69
+ values.length === 0 ||
70
+ values.some((value) => !Number.isFinite(value))
71
+ ) {
72
+ return [];
73
+ }
74
+
75
+ return values;
76
+ }
77
+
78
+ /**
79
+ * Return a usable positive integer plot width.
80
+ *
81
+ * @param {number} width
82
+ * Requested plot width.
83
+ * @returns {number}
84
+ * Positive integer width.
85
+ */
86
+ function normaliseWidth(width) {
87
+ if (!Number.isFinite(width)) {
88
+ return defaultWidth;
89
+ }
90
+
91
+ return Math.max(Math.floor(width), 1);
92
+ }
93
+
94
+ /**
95
+ * Resolve the shared scale and extrema indexes for a trend.
96
+ *
97
+ * @param {number[]} values
98
+ * Finite trend values.
99
+ * @returns {object}
100
+ * Trend range and first extrema indexes.
101
+ */
102
+ function resolveRange(values) {
103
+ let minimum = values[0];
104
+ let maximum = values[0];
105
+ let minimumIndex = 0;
106
+ let maximumIndex = 0;
107
+
108
+ for (let index = 1; index < values.length; index += 1) {
109
+ if (values[index] < minimum) {
110
+ minimum = values[index];
111
+ minimumIndex = index;
112
+ }
113
+
114
+ if (values[index] > maximum) {
115
+ maximum = values[index];
116
+ maximumIndex = index;
117
+ }
118
+ }
119
+
120
+ return {
121
+ maximum,
122
+ maximumIndex,
123
+ minimum,
124
+ minimumIndex,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Reduce values to deterministic plotted samples while retaining key points.
130
+ *
131
+ * @param {number[]} values
132
+ * Finite trend values.
133
+ * @param {number} width
134
+ * Maximum number of plotted samples.
135
+ * @param {object} range
136
+ * Shared trend range and extrema indexes.
137
+ * @returns {number[]}
138
+ * Sampled values in their original order.
139
+ */
140
+ function sampleValues(values, width, range) {
141
+ if (values.length <= width) {
142
+ return values;
143
+ }
144
+
145
+ const latestIndex = values.length - 1;
146
+ const requiredIndexes = [...new Set([latestIndex, range.minimumIndex, range.maximumIndex])];
147
+ const selectedIndexes = new Set(requiredIndexes.slice(0, width));
148
+
149
+ const evenlySpacedIndexes = Array.from({ length: width }, (_, index) =>
150
+ Math.round((index * (values.length - 1)) / Math.max(width - 1, 1)),
151
+ );
152
+
153
+ for (const index of evenlySpacedIndexes) {
154
+ if (selectedIndexes.size >= width) {
155
+ break;
156
+ }
157
+
158
+ selectedIndexes.add(index);
159
+ }
160
+
161
+ for (let index = 0; selectedIndexes.size < width; index += 1) {
162
+ selectedIndexes.add(index);
163
+ }
164
+
165
+ return [...selectedIndexes].sort((left, right) => left - right).map((index) => values[index]);
166
+ }
167
+
168
+ /**
169
+ * Map one value onto the active graduated character set.
170
+ *
171
+ * @param {number} value
172
+ * Value to render.
173
+ * @param {object} range
174
+ * Full trend range used for the shared scale.
175
+ * @param {string} symbols
176
+ * Graduated characters.
177
+ * @returns {string}
178
+ * One trend character.
179
+ */
180
+ function renderValue(value, range, symbols) {
181
+ if (range.minimum === range.maximum) {
182
+ return symbols[Math.floor((symbols.length - 1) / 2)];
183
+ }
184
+
185
+ const ratio = (value - range.minimum) / (range.maximum - range.minimum);
186
+ const symbolIndex = Math.round(ratio * (symbols.length - 1));
187
+
188
+ return symbols[symbolIndex];
189
+ }
190
+
191
+ /**
192
+ * Apply a semantic tone only when colour output is explicitly enabled.
193
+ *
194
+ * @param {string} trend
195
+ * Uncoloured trend text.
196
+ * @param {object} options
197
+ * Rendering options.
198
+ * @returns {string}
199
+ * Trend text with optional ANSI colour.
200
+ */
201
+ function renderTrend(trend, options) {
202
+ if (options.colour !== true) {
203
+ return trend;
204
+ }
205
+
206
+ const tone = getToneColour(options.tone, options) === null ? defaultTone : options.tone;
207
+
208
+ return foreground(trend, tone, options);
209
+ }