@lewishowles/cli-style 0.17.0 → 0.17.1

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 CHANGED
@@ -1,6 +1,23 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.17.1 - 2026-10-20
4
+
5
+ ### Changed
6
+
7
+ - The package now depends on `get-east-asian-width` to measure wide characters.
8
+
9
+ ### Fixed
10
+
11
+ - Spans, chips, status lines and empty states now keep their own colour, weight and chip background after caller-coloured text.
12
+ - Labelled lines now keep their bold prefix and tone colour after a caller-coloured icon or label.
13
+ - Panels now keep their background, text colour and bold title to the end of a line that holds coloured text.
14
+ - Panels now fill every line to the panel width when it holds coloured text or wide characters.
15
+ - Text wrapping now measures coloured text, wide characters, and joined emoji by the columns they fill. It keeps joined emoji and accented letters whole, and wraps before a wide character would pass the limit.
16
+ - `stripAnsi()` now removes terminal hyperlinks and other terminal control sequences as well as colours.
17
+ - Tables now line up columns when values contain coloured or wide text.
18
+ - Tables now cut overflowing cells by visible columns while keeping colours and terminal links.
19
+ - Rows, row groups and labelled lines now line up when labels contain coloured or wide text.
20
+ - Dividers and bar charts now line up when labels contain coloured or wide text.
4
21
 
5
22
  ## 0.17.0 - 2026-10-03
6
23
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lewishowles-cli-style"
3
- version = "0.17.0"
3
+ version = "0.17.1"
4
4
  description = "Python adapter for the shared cli-style binary."
5
5
  authors = [{ name = "Lewis Howles" }]
6
6
  license = { text = "MIT" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lewishowles/cli-style",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Shared terminal output styles for Lewis Howles tools, diagnostics, and package CLIs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,6 +38,9 @@
38
38
  "smoke:binary": "./dist/bin/cli-style render status --plain < tests/fixtures/render/status-success.json",
39
39
  "test:unit": "bun test"
40
40
  },
41
+ "dependencies": {
42
+ "get-east-asian-width": "^1.7.0"
43
+ },
41
44
  "devDependencies": {
42
45
  "@stylistic/eslint-plugin": "^5.10.0",
43
46
  "vite": "npm:@voidzero-dev/vite-plus-core@0.2.8",
@@ -1,11 +1,42 @@
1
+ import { eastAsianWidth } from "get-east-asian-width";
2
+
1
3
  import { resolveColourValue } from "../theme/colours.js";
2
4
 
3
5
  // ANSI reset sequence used after coloured output.
4
6
  const reset = "\u001b[0m";
5
7
 
6
- // Matches simple SGR ANSI sequences used by this package.
8
+ // The escape character that starts every ANSI sequence.
7
9
  const escapeCharacter = String.fromCharCode(27);
8
- const ansiPattern = new RegExp(`${escapeCharacter}\\[[\\d;]*m`, "gu");
10
+
11
+ // The bell character that can end an OSC sequence, such as a terminal hyperlink.
12
+ const bellCharacter = String.fromCharCode(7);
13
+
14
+ // Matches ANSI CSI sequences, such as colours, and OSC sequences, such as hyperlinks, which take up no columns on screen.
15
+ export const ansiPattern = new RegExp(
16
+ `${escapeCharacter}(?:\\[[0-?]*[ -/]*[@-~]|\\][^${bellCharacter}${escapeCharacter}]*(?:${bellCharacter}|${escapeCharacter}\\\\))`,
17
+ "gu",
18
+ );
19
+
20
+ // Matches the same sequences as a captured group, so splitting text on it keeps each sequence as its own piece.
21
+ const ansiSplitPattern = new RegExp(`(${ansiPattern.source})`, "u");
22
+
23
+ // Matches a terminal link sequence and captures its address, which is empty when the sequence ends a link.
24
+ const hyperlinkPattern = new RegExp(
25
+ `^${escapeCharacter}\\]8;[^;]*;([^${bellCharacter}${escapeCharacter}]*)`,
26
+ "u",
27
+ );
28
+
29
+ // The sequences that clear every colour and text style at once.
30
+ const clearStyleCodes = [reset, "\u001b[m"];
31
+
32
+ // Splits text into the characters people see, so a joined emoji or an accented letter counts as one.
33
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
34
+
35
+ // Matches emoji presentation characters or the selector that requests emoji presentation.
36
+ const emojiPattern = /[\p{Emoji_Presentation}\uFE0F]/u;
37
+
38
+ // Matches a cluster made only of combining marks or invisible characters, such as a lone joiner, which take no columns.
39
+ const zeroWidthPattern = /^[\p{Mark}\p{Default_Ignorable_Code_Point}]+$/u;
9
40
 
10
41
  // ANSI style sequences supported before primitive renderers exist.
11
42
  const styleCodes = {
@@ -73,6 +104,36 @@ export function style(value, style, options = {}) {
73
104
  return `${styleCode}${value}${reset}`;
74
105
  }
75
106
 
107
+ /**
108
+ * Switch the given styles back on after every full reset inside text, so an outer style
109
+ * carries on past coloured text placed inside it. Coloured text ends with a full reset,
110
+ * written as `\u001b[0m` or the shorter `\u001b[m`, which would otherwise turn the outer
111
+ * style off as well.
112
+ *
113
+ * @param {string} value
114
+ * Text that may hold coloured parts.
115
+ * @param {string[]} stylesToRestore
116
+ * The styles to switch back on, each rendered on an empty string, such as
117
+ * `style("", "bold", options)`. An empty entry adds nothing.
118
+ * @returns {string}
119
+ * The text with the styles switched back on after each full reset.
120
+ */
121
+ export function restoreStyles(value, stylesToRestore) {
122
+ // The codes that turn the styles on, with each closing reset cut off.
123
+ const openingCodes = stylesToRestore
124
+ .map((styleToRestore) => styleToRestore.slice(0, -reset.length))
125
+ .join("");
126
+
127
+ // The text with the styles switched back on after each reset form handled so far.
128
+ let restoredValue = value;
129
+
130
+ for (const clearStyleCode of clearStyleCodes) {
131
+ restoredValue = restoredValue.replaceAll(clearStyleCode, `${clearStyleCode}${openingCodes}`);
132
+ }
133
+
134
+ return restoredValue;
135
+ }
136
+
76
137
  /**
77
138
  * Remove ANSI escape sequences from text.
78
139
  *
@@ -85,6 +146,130 @@ export function stripAnsi(value) {
85
146
  return value.replace(ansiPattern, "");
86
147
  }
87
148
 
149
+ /**
150
+ * Count the terminal columns that text fills on screen. ANSI sequences take no
151
+ * columns, and East Asian wide characters and emoji take two.
152
+ *
153
+ * @param {string} value
154
+ * The text to measure, which may contain ANSI sequences.
155
+ * @returns {number}
156
+ * The number of columns the text fills.
157
+ */
158
+ export function visibleWidth(value) {
159
+ let width = 0;
160
+
161
+ for (const { segment } of graphemeSegmenter.segment(stripAnsi(value))) {
162
+ if (zeroWidthPattern.test(segment)) {
163
+ continue;
164
+ }
165
+
166
+ if (eastAsianWidth(segment.codePointAt(0)) === 2 || emojiPattern.test(segment)) {
167
+ width += 2;
168
+ continue;
169
+ }
170
+
171
+ width += 1;
172
+ }
173
+
174
+ return width;
175
+ }
176
+
177
+ /**
178
+ * Add trailing spaces until text fills a number of terminal columns. Colour
179
+ * codes take no space and wide characters take two, so coloured and wide text
180
+ * pads to the same width as plain text.
181
+ *
182
+ * @param {string} value
183
+ * The text to pad, which may contain ANSI colour codes.
184
+ * @param {number} width
185
+ * The number of columns the text should fill.
186
+ * @returns {string}
187
+ * The padded text, or the text unchanged when it already fills the width.
188
+ */
189
+ export function padVisible(value, width) {
190
+ return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
191
+ }
192
+
193
+ /**
194
+ * Split text at a terminal column without breaking a colour code, a link or a
195
+ * character. A character that would cross the column, such as a two-column 界
196
+ * at the edge, starts the remaining text instead.
197
+ *
198
+ * When the cut falls inside coloured or linked text, the first piece ends by
199
+ * closing the colour and the link, and the remaining text starts by reopening
200
+ * them, so each piece can be printed on a line of its own.
201
+ *
202
+ * @param {string} value
203
+ * The text to split, which may contain ANSI colour codes and terminal links.
204
+ * @param {number} width
205
+ * The most columns the first piece may fill.
206
+ * @returns {{ head: string, tail: string }}
207
+ * The first piece and the remaining text, which is empty when the whole
208
+ * value fits.
209
+ */
210
+ export function sliceVisible(value, width) {
211
+ // Splitting on a captured pattern puts plain text at even indexes and ANSI sequences at odd ones.
212
+ const parts = value.split(ansiSplitPattern);
213
+
214
+ // The link open at the current position, with the sequences that close and reopen it.
215
+ let activeLink = null;
216
+ // The colour and style codes in effect at the current position, in the order they were set.
217
+ let activeStyles = [];
218
+ // The columns filled so far.
219
+ let columns = 0;
220
+ // The position in the original value reached so far.
221
+ let cursor = 0;
222
+
223
+ for (const [index, part] of parts.entries()) {
224
+ if (index % 2 === 0) {
225
+ for (const { segment } of graphemeSegmenter.segment(part)) {
226
+ const segmentWidth = visibleWidth(segment);
227
+
228
+ if (columns + segmentWidth > width) {
229
+ const head =
230
+ value.slice(0, cursor) +
231
+ (activeLink?.closer ?? "") +
232
+ (activeStyles.length > 0 ? reset : "");
233
+
234
+ const tail = activeStyles.join("") + (activeLink?.opener ?? "") + value.slice(cursor);
235
+
236
+ return { head, tail };
237
+ }
238
+
239
+ columns += segmentWidth;
240
+ cursor += segment.length;
241
+ }
242
+
243
+ continue;
244
+ }
245
+
246
+ if (clearStyleCodes.includes(part)) {
247
+ activeStyles = [];
248
+ } else if (part.startsWith(`${escapeCharacter}[`) && part.endsWith("m")) {
249
+ activeStyles.push(part);
250
+ }
251
+
252
+ const hyperlink = part.match(hyperlinkPattern);
253
+
254
+ if (hyperlink !== null) {
255
+ // The closing sequence ends the same way as the opening one, with a bell or an escape and backslash.
256
+ const terminator = part.endsWith(bellCharacter) ? bellCharacter : `${escapeCharacter}\\`;
257
+
258
+ activeLink =
259
+ hyperlink[1] === ""
260
+ ? null
261
+ : {
262
+ closer: `${escapeCharacter}]8;;${terminator}`,
263
+ opener: part,
264
+ };
265
+ }
266
+
267
+ cursor += part.length;
268
+ }
269
+
270
+ return { head: value, tail: "" };
271
+ }
272
+
88
273
  /**
89
274
  * Wrap text in ANSI colour codes.
90
275
  *
@@ -1,13 +1,14 @@
1
+ import { ansiPattern, visibleWidth } from "./ansi.js";
2
+
1
3
  // Width used when a caller doesn't provide one.
2
4
  const defaultWrapWidth = 64;
3
- const escapeCharacter = String.fromCharCode(27);
4
- const bellCharacter = String.fromCharCode(7);
5
5
 
6
- // Matches ANSI CSI and OSC escape sequences so they can be measured as zero-width.
7
- const ansiEscapePattern = new RegExp(
8
- `${escapeCharacter}(?:\\[[0-?]*[ -/]*[@-~]|\\][^${bellCharacter}]*(?:${bellCharacter}|${escapeCharacter}\\\\))`,
9
- "gu",
10
- );
6
+ // Splits text into the characters people see, so wrapping never breaks a joined emoji apart.
7
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
8
+
9
+ // Line breaks end the current wrapped line and take no columns. Windows CR+LF arrives from the
10
+ // segmenter as one character.
11
+ const lineBreaks = new Set(["\n", "\r\n"]);
11
12
 
12
13
  /**
13
14
  * Wrap text to a maximum visible width, treating ANSI escape sequences as zero-width.
@@ -27,7 +28,7 @@ export function wrapText(text, width = defaultWrapWidth) {
27
28
  let line = [];
28
29
 
29
30
  for (const token of tokens) {
30
- if (token.value === "\n") {
31
+ if (lineBreaks.has(token.value)) {
31
32
  lines.push(renderTokens(line));
32
33
  line = [];
33
34
  continue;
@@ -35,7 +36,7 @@ export function wrapText(text, width = defaultWrapWidth) {
35
36
 
36
37
  line.push(token);
37
38
 
38
- while (visibleWidth(line) > wrapWidth) {
39
+ while (tokensWidth(line) > wrapWidth) {
39
40
  const whitespaceIndex = findLastWhitespace(line);
40
41
 
41
42
  if (whitespaceIndex > 0) {
@@ -77,7 +78,7 @@ function resolveWrapWidth(width) {
77
78
  }
78
79
 
79
80
  /**
80
- * Split text into visible characters and ANSI escape sequences.
81
+ * Split text into the characters people see and ANSI escape sequences.
81
82
  *
82
83
  * @param {string} text
83
84
  * Text to tokenise.
@@ -89,7 +90,7 @@ function tokenise(text) {
89
90
 
90
91
  let textStart = 0;
91
92
 
92
- for (const match of text.matchAll(ansiEscapePattern)) {
93
+ for (const match of text.matchAll(ansiPattern)) {
93
94
  appendTextTokens(tokens, text.slice(textStart, match.index));
94
95
  tokens.push({
95
96
  value: match[0],
@@ -104,7 +105,9 @@ function tokenise(text) {
104
105
  }
105
106
 
106
107
  /**
107
- * Append one token per character of plain text to a token list.
108
+ * Append one token per visible character of plain text to a token list, keeping
109
+ * joined emoji and accented letters whole. Line breaks, including Windows CR+LF,
110
+ * take no columns.
108
111
  *
109
112
  * @param {object[]} tokens
110
113
  * Token list to append to.
@@ -113,10 +116,10 @@ function tokenise(text) {
113
116
  * @returns {void}
114
117
  */
115
118
  function appendTextTokens(tokens, text) {
116
- for (const value of text) {
119
+ for (const { segment: value } of graphemeSegmenter.segment(text)) {
117
120
  tokens.push({
118
121
  value,
119
- visibleWidth: value === "\n" ? 0 : 1,
122
+ visibleWidth: lineBreaks.has(value) ? 0 : visibleWidth(value),
120
123
  });
121
124
  }
122
125
  }
@@ -129,7 +132,7 @@ function appendTextTokens(tokens, text) {
129
132
  * @returns {number}
130
133
  * Total visible width.
131
134
  */
132
- function visibleWidth(tokens) {
135
+ function tokensWidth(tokens) {
133
136
  return tokens.reduce((width, token) => width + token.visibleWidth, 0);
134
137
  }
135
138
 
@@ -155,6 +158,7 @@ function findLastWhitespace(tokens) {
155
158
 
156
159
  /**
157
160
  * Find the index to split at when a line has no whitespace to break on.
161
+ * A single character wider than the limit stays whole on its own line.
158
162
  *
159
163
  * @param {object[]} tokens
160
164
  * Tokens to search.
@@ -167,11 +171,13 @@ function findHardSplitIndex(tokens, width) {
167
171
  let currentWidth = 0;
168
172
 
169
173
  for (let index = 0; index < tokens.length; index += 1) {
170
- currentWidth += tokens[index].visibleWidth;
174
+ const nextWidth = currentWidth + tokens[index].visibleWidth;
171
175
 
172
- if (currentWidth >= width) {
173
- return index + 1;
176
+ if (nextWidth > width && currentWidth > 0) {
177
+ return index;
174
178
  }
179
+
180
+ currentWidth = nextWidth;
175
181
  }
176
182
 
177
183
  return tokens.length;
@@ -8,7 +8,7 @@ import { diffBlock } from "../patterns/diff-block.js";
8
8
  import { nextStepBlock } from "../patterns/next-step-block.js";
9
9
  import { quoteBlock } from "../patterns/quote-block.js";
10
10
  import { taskSummary } from "../patterns/task-summary.js";
11
- import { stripAnsi } from "../formatters/ansi.js";
11
+ import { foreground, sliceVisible, visibleWidth } from "../formatters/ansi.js";
12
12
  import { renderSpinnerFrame } from "../interactive/spinner.js";
13
13
  import { barChart } from "../primitives/bar-chart.js";
14
14
  import { bulletList } from "../primitives/bullet-list.js";
@@ -303,27 +303,26 @@ function clampGalleryOutput(output, width) {
303
303
  }
304
304
 
305
305
  /**
306
- * Truncate one line to a visible width.
306
+ * Cut one gallery line to the gallery width, measured in screen columns.
307
307
  *
308
308
  * @param {string} line
309
- * Output line.
309
+ * The output line, which may contain colour codes and wide characters.
310
310
  * @param {number} width
311
- * Requested visible width.
311
+ * The most columns the line may fill.
312
312
  * @returns {string}
313
- * Truncated line.
313
+ * The line unchanged when it fits, or cut with a trailing `~` that marks
314
+ * the cut. Colour codes and links stay whole and are closed before the `~`.
314
315
  */
315
316
  function clampLine(line, width) {
316
- const plainLine = stripAnsi(line);
317
-
318
- if (plainLine.length <= width) {
317
+ if (visibleWidth(line) <= width) {
319
318
  return line;
320
319
  }
321
320
 
322
321
  if (width <= 1) {
323
- return plainLine.slice(0, width);
322
+ return sliceVisible(line, width).head;
324
323
  }
325
324
 
326
- return `${plainLine.slice(0, width - 1)}~`;
325
+ return `${sliceVisible(line, width - 1).head}~`;
327
326
  }
328
327
 
329
328
  /**
@@ -760,9 +759,21 @@ function renderPrimitives(options) {
760
759
  "",
761
760
  "Chips",
762
761
  renderChipExamples(options),
762
+ chip(`${foreground("coloured", "danger", options)} label`, "success", options),
763
763
  "",
764
764
  "Statuses",
765
765
  ...renderStatusExamples(options),
766
+ status(resultTypes.SUCCESS, "caller-coloured label", {
767
+ ...options,
768
+ label: `${foreground("Coloured", "danger", options)} status`,
769
+ }),
770
+ "",
771
+ "Dividers",
772
+ divider({
773
+ ...options,
774
+ dividerWidth: 32,
775
+ label: foreground("界面", "info", options),
776
+ }),
766
777
  "",
767
778
  "Panels",
768
779
  ...renderPanelExamples(options),
@@ -804,6 +815,15 @@ function renderPrimitives(options) {
804
815
  icon: "!",
805
816
  tone: "warning",
806
817
  }),
818
+ labelledLine(
819
+ foreground("界面", "info", options),
820
+ "Coloured wide labels keep wrapped text aligned.",
821
+ {
822
+ ...options,
823
+ icon: foreground("界", "warning", options),
824
+ width: 32,
825
+ },
826
+ ),
807
827
  emptyState("No results", "tone: muted", options),
808
828
  errorBlock("Failed", ["tone: danger"], {
809
829
  ...options,
@@ -836,6 +856,16 @@ function renderPrimitives(options) {
836
856
  ...options,
837
857
  labelWidth: 8,
838
858
  }),
859
+ row(
860
+ foreground("界面", "info", options),
861
+ "Coloured wide labels and separators align wrapped text.",
862
+ {
863
+ ...options,
864
+ labelWidth: 8,
865
+ separator: foreground(" => ", "warning", options),
866
+ width: 36,
867
+ },
868
+ ),
839
869
  row("Bundle", "is 22.3 KB, above the 12.0 KB budget", {
840
870
  ...options,
841
871
  labelWidth: 8,
@@ -879,6 +909,10 @@ function renderPrimitives(options) {
879
909
  label: "Checks",
880
910
  value: "type-check, unit, build",
881
911
  },
912
+ {
913
+ label: foreground("界面", "info", options),
914
+ value: "Coloured wide labels share the value column",
915
+ },
882
916
  {
883
917
  label: "Bundle",
884
918
  result: resultTypes.FAILED,
@@ -904,6 +938,10 @@ function renderPrimitives(options) {
904
938
  function renderBulletListExamples(options) {
905
939
  return [
906
940
  bulletList(["Install dependencies", "Run the unit tests", "Review the output"], options),
941
+ bulletList([foreground("界面 item wraps beside its bullet", "info", options)], {
942
+ ...options,
943
+ width: 18,
944
+ }),
907
945
  bulletList(["This item wraps so continuation lines stay aligned with the item text."], {
908
946
  ...options,
909
947
  width: 40,
@@ -959,6 +997,10 @@ function renderTableExample(options) {
959
997
  check: "unit",
960
998
  result: "warning",
961
999
  },
1000
+ {
1001
+ check: "界面",
1002
+ result: foreground("passed", "success", options),
1003
+ },
962
1004
  ],
963
1005
  });
964
1006
  }
@@ -990,7 +1032,7 @@ function renderOverflowTableExample(options, overflow) {
990
1032
  ],
991
1033
  rows: [
992
1034
  {
993
- detail: "packages/cli-style/src/primitives/table.js",
1035
+ detail: foreground("界面/packages/cli-style/src/primitives/table.js", "success", options),
994
1036
  source: "Table",
995
1037
  },
996
1038
  ],
@@ -1009,6 +1051,10 @@ function renderBarChartExample(options) {
1009
1051
  return barChart({
1010
1052
  ...options,
1011
1053
  rows: [
1054
+ {
1055
+ label: foreground("界面", "info", options),
1056
+ value: 8,
1057
+ },
1012
1058
  {
1013
1059
  label: "positive",
1014
1060
  value: 10,
@@ -1115,7 +1161,7 @@ function renderProgressExamples(options) {
1115
1161
  }
1116
1162
 
1117
1163
  /**
1118
- * Render all panel tone examples.
1164
+ * Render one panel for each tone, then a panel holding coloured output.
1119
1165
  *
1120
1166
  * @param {object} options
1121
1167
  * Rendering options.
@@ -1123,7 +1169,8 @@ function renderProgressExamples(options) {
1123
1169
  * Panel examples.
1124
1170
  */
1125
1171
  function renderPanelExamples(options) {
1126
- return panelToneExamples.map((tone) =>
1172
+ // One panel for each tone.
1173
+ const toneExamples = panelToneExamples.map((tone) =>
1127
1174
  panel({
1128
1175
  ...options,
1129
1176
  lines: [`tone: ${tone}`],
@@ -1131,6 +1178,25 @@ function renderPanelExamples(options) {
1131
1178
  tone,
1132
1179
  }),
1133
1180
  );
1181
+
1182
+ // A panel holding a coloured status line and coloured values, to show the panel
1183
+ // background carrying on after them.
1184
+ const nestedStylesExample = panel({
1185
+ ...options,
1186
+ lines: [
1187
+ status(resultTypes.SUCCESS, "Checks passed", options),
1188
+ rowGroup({
1189
+ ...options,
1190
+ rows: [
1191
+ { label: "Source", value: "main", valueColour: "success" },
1192
+ { label: "Checks", value: "unit", valueColour: "success" },
1193
+ ],
1194
+ }),
1195
+ ],
1196
+ title: "Nested styles",
1197
+ });
1198
+
1199
+ return [...toneExamples, nestedStylesExample];
1134
1200
  }
1135
1201
 
1136
1202
  /**
@@ -1,4 +1,4 @@
1
- import { foreground } from "../formatters/ansi.js";
1
+ import { foreground, padVisible, visibleWidth } from "../formatters/ansi.js";
2
2
  import { chartColours } from "../theme/colours.js";
3
3
 
4
4
  // Chart bars use the same compact default width as progress bars.
@@ -27,7 +27,7 @@ export function barChart(options = {}) {
27
27
  return "";
28
28
  }
29
29
 
30
- const labelWidth = Math.max(...rows.map((row) => row.label.length));
30
+ const labelWidth = Math.max(...rows.map((row) => visibleWidth(row.label)));
31
31
  const maximum = Math.max(...rows.map((row) => Math.abs(row.value)), 1);
32
32
  const width = normaliseWidth(options.barWidth);
33
33
 
@@ -93,7 +93,7 @@ function renderRow(row, labelWidth, maximum, width, options) {
93
93
  const barWidth = Math.round((Math.abs(row.value) / maximum) * width);
94
94
  const symbol = options.unicode === false ? "#" : "█";
95
95
  const bar = symbol.repeat(barWidth).padEnd(width, " ");
96
- const label = row.label.padEnd(labelWidth, " ");
96
+ const label = padVisible(row.label, labelWidth);
97
97
 
98
98
  if (options.colour !== true) {
99
99
  return `${label} ${bar} ${row.value}`;
@@ -1,4 +1,4 @@
1
- import { background, foreground, style } from "../formatters/ansi.js";
1
+ import { background, foreground, restoreStyles, style } from "../formatters/ansi.js";
2
2
  import { chipColours } from "../theme/colours.js";
3
3
 
4
4
  // Chip style used when the requested tone is not configured.
@@ -25,7 +25,15 @@ export function chip(label, tone = fallbackTone, options = {}) {
25
25
  return `[${label}]`;
26
26
  }
27
27
 
28
- const paddedLabel = ` ${label} `;
28
+ // The label with the chip's fill, bold and tone switched back on after any coloured text,
29
+ // so the rest of the chip keeps its look.
30
+ const restoredLabel = restoreStyles(label, [
31
+ background("", token.background, options),
32
+ style("", "bold", options),
33
+ foreground("", token.foreground, options),
34
+ ]);
35
+
36
+ const paddedLabel = ` ${restoredLabel} `;
29
37
  const colouredLabel = foreground(paddedLabel, token.foreground, options);
30
38
  const boldLabel = style(colouredLabel, "bold", options);
31
39
 
@@ -1,4 +1,4 @@
1
- import { foreground } from "../formatters/ansi.js";
1
+ import { foreground, visibleWidth } from "../formatters/ansi.js";
2
2
 
3
3
  // Dividers use enough width to read as a section break in compact output.
4
4
  const defaultWidth = 40;
@@ -33,7 +33,7 @@ export function divider(options = {}) {
33
33
  const character = options.character ?? defaultCharacter;
34
34
  const label = options.label ?? "";
35
35
  const width = options.dividerWidth ?? defaultWidth;
36
- const lineWidth = Math.max(width - label.length - labelGap(label), 0);
36
+ const lineWidth = Math.max(width - visibleWidth(label) - labelGap(label), 0);
37
37
  const line = character.repeat(lineWidth);
38
38
  const output = label === "" ? line : `${label} ${line}`;
39
39
 
@@ -1,4 +1,4 @@
1
- import { foreground, style } from "../formatters/ansi.js";
1
+ import { foreground, restoreStyles, style } from "../formatters/ansi.js";
2
2
  import { getResultSymbol, resultTypes } from "../theme/results.js";
3
3
  import { labelledLine } from "./labelled-line.js";
4
4
  import { panel } from "./panel.js";
@@ -48,7 +48,13 @@ export function emptyState(title = "No results", detail = "", options = {}) {
48
48
  return `${prefix}${detailText}`;
49
49
  }
50
50
 
51
- const renderedPrefix = style(foreground(prefix, "muted", options), "bold", options);
51
+ // The prefix with its bold and muted tone switched back on after a coloured title.
52
+ const restoredPrefix = restoreStyles(prefix, [
53
+ style("", "bold", options),
54
+ foreground("", "muted", options),
55
+ ]);
56
+
57
+ const renderedPrefix = style(foreground(restoredPrefix, "muted", options), "bold", options);
52
58
 
53
59
  return `${renderedPrefix}${detailText}`;
54
60
  }
@@ -1,4 +1,4 @@
1
- import { foreground, style } from "../formatters/ansi.js";
1
+ import { foreground, restoreStyles, style, visibleWidth } from "../formatters/ansi.js";
2
2
  import { wrapText } from "../formatters/wrap.js";
3
3
 
4
4
  // Falls back to the informational tone when no colour token is given.
@@ -44,20 +44,19 @@ export function labelledLine(label = "", message = "", options = {}) {
44
44
  return `${prefix}${messageSeparator}${messageText}`;
45
45
  }
46
46
 
47
- const wrapWidth = resolveWrapWidth(prefixText, options);
47
+ const wrapWidth = resolveWrapWidth(prefix, options);
48
48
  const messageLines = wrapText(messageText, wrapWidth);
49
- const continuationIndent = " ".repeat(prefixText.length + messageSeparator.length);
49
+ const continuationIndent = " ".repeat(visibleWidth(prefix) + visibleWidth(messageSeparator));
50
50
  const wrappedMessage = messageLines.join(`\n${continuationIndent}`);
51
51
 
52
52
  return `${prefix}${messageSeparator}${wrappedMessage}`;
53
53
  }
54
54
 
55
55
  /**
56
- * Colour the icon/label prefix, sized from its plain-text length so the
57
- * continuation indent lines up whether or not colour is applied.
56
+ * Colour the icon and label prefix in the line's tone when colour is on.
58
57
  *
59
58
  * @param {string} prefix
60
- * Plain-text icon and label prefix.
59
+ * Icon and label prefix. The icon or label may already hold colour.
61
60
  * @param {object} options
62
61
  * Rendering options.
63
62
  * @returns {string}
@@ -72,13 +71,24 @@ function renderPrefix(prefix, options) {
72
71
  return prefix;
73
72
  }
74
73
 
75
- const colouredPrefix = foreground(prefix, resolveColour(options), options);
74
+ // The caller's tone, or "info" when none is given.
75
+ const tone = resolveColour(options);
76
+
77
+ // The prefix with bold and the line's colour switched back on after a coloured icon or
78
+ // label, so the rest of the prefix keeps them.
79
+ const restoredPrefix = restoreStyles(prefix, [
80
+ style("", "bold", options),
81
+ foreground("", tone, options),
82
+ ]);
83
+
84
+ // The whole prefix in the line's colour, ready to be made bold.
85
+ const colouredPrefix = foreground(restoredPrefix, tone, options);
76
86
 
77
87
  return style(colouredPrefix, "bold", options);
78
88
  }
79
89
 
80
90
  /**
81
- * Build the plain-text icon and label prefix.
91
+ * Build the icon and label prefix. The icon or label may already hold colour.
82
92
  *
83
93
  * @param {string} label
84
94
  * Label text.
@@ -115,7 +125,7 @@ function resolveColour(options) {
115
125
  * Work out how wide the message can be before it wraps.
116
126
  *
117
127
  * @param {string} prefix
118
- * Plain-text icon and label prefix, used to size the continuation indent.
128
+ * The icon and label prefix as it will be printed, including any colour.
119
129
  * @param {object} options
120
130
  * Rendering options.
121
131
  * @returns {number|undefined}
@@ -127,7 +137,7 @@ function resolveWrapWidth(prefix, options) {
127
137
  }
128
138
 
129
139
  if (Number.isFinite(options.width)) {
130
- return Math.max(1, options.width - prefix.length - messageSeparator.length);
140
+ return Math.max(1, options.width - visibleWidth(prefix) - visibleWidth(messageSeparator));
131
141
  }
132
142
 
133
143
  return undefined;
@@ -1,4 +1,4 @@
1
- import { background, foreground, style } from "../formatters/ansi.js";
1
+ import { background, foreground, restoreStyles, style, visibleWidth } from "../formatters/ansi.js";
2
2
  import { wrapText } from "../formatters/wrap.js";
3
3
  import { profiles } from "../profiles/profiles.js";
4
4
  import { panelColours } from "../theme/colours.js";
@@ -64,23 +64,38 @@ export function panel(options = {}) {
64
64
  * @param {object} options
65
65
  * Rendering options.
66
66
  * @returns {string[]}
67
- * The panel lines, each exactly `width` columns wide.
67
+ * The panel lines, each exactly `width` columns wide, unless a wide character
68
+ * does not fit in a panel at its minimum width.
68
69
  */
69
70
  function renderLine(line, width, isTitle, options) {
70
71
  const accent = options.unicode === false ? "|" : "▌";
71
72
  const contentWidth = width - 5;
73
+ // Whether this line is drawn with ANSI colour.
74
+ const useColour = options.colour === true;
72
75
 
73
76
  return wrapText(line, contentWidth).map((visibleLine) => {
74
- const content = ` ${visibleLine.padEnd(contentWidth, " ")} `;
77
+ // Spaces that fill the line to the content width on screen. A wide character can
78
+ // overflow a very narrow panel, so the count never drops below zero.
79
+ const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(visibleLine)));
80
+ // The line with its inner margins, ready for the panel colours.
81
+ const content = ` ${visibleLine}${padding} `;
75
82
 
76
- if (options.colour !== true) {
83
+ if (!useColour) {
77
84
  return `${accent}${content}`;
78
85
  }
79
86
 
80
87
  const renderedAccent = renderPanelColour(accent, options);
81
88
 
89
+ // The content with the panel colours switched back on after each reset inside it, so
90
+ // the panel background carries on to the right edge.
91
+ const restoredContent = restoreStyles(content, [
92
+ background("", panelColours.background, options),
93
+ foreground("", panelColours.body, options),
94
+ isTitle ? style("", "bold", options) : "",
95
+ ]);
96
+
82
97
  const renderedContent = foreground(
83
- background(content, panelColours.background, options),
98
+ background(restoredContent, panelColours.background, options),
84
99
  panelColours.body,
85
100
  options,
86
101
  );
@@ -1,3 +1,4 @@
1
+ import { visibleWidth } from "../formatters/ansi.js";
1
2
  import { getResultSymbol } from "../theme/results.js";
2
3
  import { row } from "./row.js";
3
4
 
@@ -21,7 +22,7 @@ export function rowGroup(options = {}) {
21
22
  }
22
23
 
23
24
  const labelWidth = Math.max(
24
- ...rows.map((entry) => displayedLabelLength(entry, options)),
25
+ ...rows.map((entry) => displayedLabelWidth(entry, options)),
25
26
  options.labelWidth ?? 0,
26
27
  );
27
28
 
@@ -59,18 +60,19 @@ function normaliseRows(rows) {
59
60
  }
60
61
 
61
62
  /**
62
- * Return the visible width of a row label.
63
+ * Count the columns a row's label fills, including its result symbol when it
64
+ * has one.
63
65
  *
64
66
  * @param {object} entry
65
67
  * Row entry.
66
68
  * @param {object} options
67
69
  * Group rendering options.
68
70
  * @returns {number}
69
- * Displayed label length.
71
+ * The number of terminal columns the label takes up.
70
72
  */
71
- function displayedLabelLength(entry, options) {
73
+ function displayedLabelWidth(entry, options) {
72
74
  if (typeof entry.result !== "string" || entry.result === "") {
73
- return entry.label.length;
75
+ return visibleWidth(entry.label);
74
76
  }
75
77
 
76
78
  const entryOptions = {
@@ -80,5 +82,5 @@ function displayedLabelLength(entry, options) {
80
82
 
81
83
  const symbol = getResultSymbol(entry.result, entryOptions);
82
84
 
83
- return [symbol, entry.label].join(" ").length;
85
+ return visibleWidth([symbol, entry.label].join(" "));
84
86
  }
@@ -1,4 +1,4 @@
1
- import { foreground } from "../formatters/ansi.js";
1
+ import { foreground, padVisible, visibleWidth } from "../formatters/ansi.js";
2
2
  import { wrapText } from "../formatters/wrap.js";
3
3
  import { getResultSymbol, getResultToken } from "../theme/results.js";
4
4
 
@@ -49,8 +49,8 @@ const minimumWrapWidth = 10;
49
49
  export function row(label, value = "", options = {}) {
50
50
  const resultToken = resolveResultToken(options);
51
51
  const displayLabel = renderDisplayLabel(label, resultToken, options);
52
- const labelWidth = Math.max(displayLabel.length, options.labelWidth ?? 0);
53
- const labelText = displayLabel.padEnd(labelWidth, " ");
52
+ const labelWidth = Math.max(visibleWidth(displayLabel), options.labelWidth ?? 0);
53
+ const labelText = padVisible(displayLabel, labelWidth);
54
54
  const renderedLabel = renderLabel(labelText, options);
55
55
 
56
56
  if (value === "") {
@@ -66,7 +66,7 @@ export function row(label, value = "", options = {}) {
66
66
  }
67
67
 
68
68
  const availableWrapWidth = Number.isFinite(options.width)
69
- ? options.width - labelWidth - separator.length
69
+ ? options.width - labelWidth - visibleWidth(separator)
70
70
  : defaultWrapWidth;
71
71
 
72
72
  const hasExplicitWrapWidth = options.wrapWidth !== undefined && options.wrapWidth !== null;
@@ -78,7 +78,7 @@ export function row(label, value = "", options = {}) {
78
78
  const wrapWidth = options.wrapWidth ?? availableWrapWidth;
79
79
 
80
80
  const valueLines = wrapText(value, wrapWidth);
81
- const continuationIndent = " ".repeat(labelWidth + separator.length);
81
+ const continuationIndent = " ".repeat(labelWidth + visibleWidth(separator));
82
82
  const renderedValueLines = valueLines.map((line) => renderValue(line, options));
83
83
  const wrappedValue = renderedValueLines.join(`\n${continuationIndent}`);
84
84
 
@@ -1,4 +1,4 @@
1
- import { foreground, style } from "../formatters/ansi.js";
1
+ import { foreground, restoreStyles, style } from "../formatters/ansi.js";
2
2
 
3
3
  // Inline spans use the same informational tone as hints by default.
4
4
  const defaultTone = "info";
@@ -27,7 +27,16 @@ export function span(value = "", tone = defaultTone, options = {}) {
27
27
  return value;
28
28
  }
29
29
 
30
- const renderedValue = foreground(value, tone, options);
30
+ // The text weight, bold unless the options ask for another.
31
+ const weight = options.weight ?? defaultWeight;
31
32
 
32
- return style(renderedValue, options.weight ?? defaultWeight, options);
33
+ // The text with the span's weight and tone switched back on after any coloured text.
34
+ const restoredValue = restoreStyles(value, [
35
+ style("", weight, options),
36
+ foreground("", tone, options),
37
+ ]);
38
+
39
+ const renderedValue = foreground(restoredValue, tone, options);
40
+
41
+ return style(renderedValue, weight, options);
33
42
  }
@@ -1,4 +1,4 @@
1
- import { foreground, style } from "../formatters/ansi.js";
1
+ import { foreground, restoreStyles, style } from "../formatters/ansi.js";
2
2
  import { getResultSymbol, getResultToken, resultTypes } from "../theme/results.js";
3
3
 
4
4
  /**
@@ -33,7 +33,13 @@ export function status(resultType = resultTypes.UNKNOWN, detail = "", options =
33
33
  return `${prefix}${detailText}`;
34
34
  }
35
35
 
36
- const colouredPrefix = foreground(prefix, token.tone, options);
36
+ // The prefix with its bold and tone switched back on after a coloured label.
37
+ const restoredPrefix = restoreStyles(prefix, [
38
+ style("", "bold", options),
39
+ foreground("", token.tone, options),
40
+ ]);
41
+
42
+ const colouredPrefix = foreground(restoredPrefix, token.tone, options);
37
43
  const boldPrefix = style(colouredPrefix, "bold", options);
38
44
 
39
45
  return `${boldPrefix}${detailText}`;
@@ -1,4 +1,4 @@
1
- import { foreground } from "../formatters/ansi.js";
1
+ import { foreground, padVisible, sliceVisible, visibleWidth } from "../formatters/ansi.js";
2
2
  import { defaultWidth, minimumWidth, normaliseWidth } from "../formatters/width.js";
3
3
  import { tableColours } from "../theme/colours.js";
4
4
 
@@ -112,8 +112,8 @@ function normaliseRows(rows) {
112
112
  function columnWidths(columns, rows) {
113
113
  return columns.map((column) => {
114
114
  const naturalWidth = Math.max(
115
- column.label.length,
116
- ...rows.map((row) => cellValue(row[column.key]).length),
115
+ visibleWidth(column.label),
116
+ ...rows.map((row) => visibleWidth(cellValue(row[column.key]))),
117
117
  );
118
118
 
119
119
  const maxWidth = columnMaxWidth(column);
@@ -151,24 +151,38 @@ function columnMaxWidth(column) {
151
151
  * @param {number} width
152
152
  * Column width.
153
153
  * @returns {string[]}
154
- * Lines the cell renders as; truncated to one line with a trailing
155
- * ellipsis unless the column's overflow is 'wrap'.
154
+ * Lines the cell renders as, each no wider on screen than the column
155
+ * unless one character is wider than the whole column. A value that does
156
+ * not fit is cut to one line with a trailing ellipsis, or split across
157
+ * lines when the column's overflow is 'wrap'. Coloured text keeps its
158
+ * colour on every line.
156
159
  */
157
160
  function columnLines(value, column, width) {
158
- if (value.length <= width) {
161
+ if (visibleWidth(value) <= width) {
159
162
  return [value];
160
163
  }
161
164
 
162
165
  if (column.overflow === "wrap") {
163
- return Array.from(
164
- {
165
- length: Math.ceil(value.length / width),
166
- },
167
- (_, index) => value.slice(index * width, (index + 1) * width),
168
- );
166
+ const lines = [];
167
+
168
+ let remaining = value;
169
+
170
+ while (remaining !== "") {
171
+ let { head, tail } = sliceVisible(remaining, width);
172
+
173
+ if (visibleWidth(head) === 0 && tail !== "") {
174
+ // In a one-column cell, a two-column character gets a line of its own so wrapping always moves forward.
175
+ ({ head, tail } = sliceVisible(remaining, 2));
176
+ }
177
+
178
+ lines.push(head);
179
+ remaining = tail;
180
+ }
181
+
182
+ return lines;
169
183
  }
170
184
 
171
- return [`${value.slice(0, width - 1)}…`];
185
+ return [`${sliceVisible(value, width - 1).head}…`];
172
186
  }
173
187
 
174
188
  /**
@@ -242,7 +256,7 @@ function renderTable(columns, rows, widths, options) {
242
256
  * Padded column text.
243
257
  */
244
258
  function padColumn(value, width, index, columnCount) {
245
- return index === columnCount - 1 ? value : value.padEnd(width, " ");
259
+ return index === columnCount - 1 ? value : padVisible(value, width);
246
260
  }
247
261
 
248
262
  /**
@@ -258,13 +272,13 @@ function padColumn(value, width, index, columnCount) {
258
272
  * Rendered key/value blocks.
259
273
  */
260
274
  function renderBlocks(columns, rows, options) {
261
- const labelWidth = Math.max(...columns.map((column) => column.label.length));
275
+ const labelWidth = Math.max(...columns.map((column) => visibleWidth(column.label)));
262
276
 
263
277
  return rows
264
278
  .map((row) =>
265
279
  columns
266
280
  .map((column) => {
267
- const label = renderHeader(column.label.padEnd(labelWidth, " "), options);
281
+ const label = renderHeader(padVisible(column.label, labelWidth), options);
268
282
  const value = renderCell(cellValue(row[column.key]), 0, options);
269
283
 
270
284
  return `${label}${columnSeparator}${value}`;