@lewishowles/cli-style 0.16.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,30 @@
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.
21
+
22
+ ## 0.17.0 - 2026-10-03
23
+
24
+ ### Added
25
+
26
+ - Added `quoteBlock()` (`quote-block` for `cli-style render`, `quote_block()` in Python, and `CliStyle.quoteBlock` in Swift) to show another program's output with a visible marker and optional heading.
27
+ - Python `row()` and `row_group()`, and Swift `CliStyle.row` and `CliStyle.rowGroup`, can now wrap long values with the `wrap` and `wrap_width` options (`wrapWidth` in Swift). The row group functions also accept colour and separator defaults, which each row can override.
4
28
 
5
29
  ## 0.16.0 - 2026-09-21
6
30
 
package/README.md CHANGED
@@ -63,6 +63,8 @@ The package contains `bin/cli-style` and `adapters/`.
63
63
 
64
64
  All renderers return text. JavaScript imports functions directly. CLI, Bash, Python, and Swift integrations pass JSON-compatible data to a named renderer.
65
65
 
66
+ Callers add any blank lines around rendered blocks. Renderers return strings without a trailing newline, and the CLI ends its output with one newline.
67
+
66
68
  ### JavaScript
67
69
 
68
70
  Create the `ui` instance at the CLI entrypoint, then pass `ui.options` to renderers. That keeps flag, environment, stream, colour, Unicode, and width detection in one place.
@@ -280,6 +282,7 @@ Renderer names are stable for `cli-style render`, `cli_style_render`, and Python
280
282
  | `empty-state` | `emptyState(title, detail, options)` | Use `cli_style_render` | Use `render("empty-state", ...)` | Use `CliStyle.render` | Empty result message. |
281
283
  | `error-block` | `errorBlock(title, lines, options)` | Use `cli_style_render` | Use `render("error-block", ...)` | Use `CliStyle.render` | Structured error block. |
282
284
  | `diff-block` | `diffBlock(diff, options)` | Use `cli_style_render` | Use `render("diff-block", ...)` | Use `CliStyle.render` | Structured added, removed, context, and header lines. |
285
+ | `quote-block` | `quoteBlock(quote, options)` | Use `cli_style_render` | `quote_block(lines, title, width)` | `CliStyle.quoteBlock(...)` | Output lines with a left marker and optional heading. |
283
286
 
284
287
  Use `render("<renderer>", data, ...)` (Python) or `CliStyle.render(_:data:options:)` (Swift) for aggregate patterns (`diagnostic-report`, `agent-transcript`, `compact-data-table`) or any renderer with multi-item fields.
285
288
 
@@ -317,6 +320,8 @@ Use these shapes with `cli-style render`, `cli_style_render`, or Python `render(
317
320
  - `result`: optional result type for a symbol and tone
318
321
  - `labelWidth`: optional minimum label width
319
322
  - `labelColour`, `valueColour`, `separator`: optional presentation overrides
323
+ - `wrap`: set to `false` to keep the value on one line
324
+ - `wrapWidth`: optional value width when wrapping is enabled
320
325
 
321
326
  ### `row-group`
322
327
 
@@ -337,7 +342,10 @@ Use these shapes with `cli-style render`, `cli_style_render`, or Python `render(
337
342
 
338
343
  - `rows`: labelled values to align and render
339
344
  - `labelWidth`: optional minimum width for every label
340
- - Per-row fields match `row`, including `label`, `value`, `result`, `labelColour`, `valueColour`, and `separator`
345
+ - `labelColour`, `valueColour`, `separator`: optional defaults for every row
346
+ - `wrap`: set to `false` to keep every value on one line
347
+ - `wrapWidth`: optional value width when wrapping is enabled
348
+ - Each row takes the same fields as `row`. Row options other than `labelWidth` override the group defaults
341
349
 
342
350
  ### `bullet-list`
343
351
 
@@ -447,6 +455,19 @@ Patterns are built-in composite renderers for common report shapes. They use the
447
455
  | `next-step-block` | `nextStepBlock(nextStep, options)` | Next-step guidance. | `title`, `next`, `reason`, `commands`, `alternatives` |
448
456
  | `compact-data-table` | `compactDataTable(data, options)` | Titled compact data table. | `title`, `summary`, `columns`, `rows` |
449
457
  | `diff-block` | `diffBlock(diff, options)` | Structured diff presentation. | `title`, `path`, `lines` |
458
+ | `quote-block` | `quoteBlock(quote, options)` | Quoted output with a visible marker. | `lines`, `title`, `width` |
459
+
460
+ ### `quote-block`
461
+
462
+ Pass output lines with an optional title and total width. Long lines wrap with a marker beside every continuation line, and empty lines are kept. The marker is `│`, or `|` when Unicode is off, and it is muted when colour is enabled. Plain and CI output always use an uncoloured `|`. ANSI colour codes in the title and lines are removed. The `json` profile returns the input unchanged.
463
+
464
+ ```js
465
+ quoteBlock({
466
+ lines: ["The command returned this explanation.", "A second output line."],
467
+ title: "Command output",
468
+ width: 48,
469
+ });
470
+ ```
450
471
 
451
472
  ### `diff-block`
452
473
 
@@ -22,6 +22,20 @@ print(render("status", {"type": "success", "label": "Build passed"}))
22
22
 
23
23
  If the `cli-style` binary is not available, `render()` returns sorted plain `key: value` text by default. Pass `raise_on_missing=True` to raise `CliStyleNotFoundError` instead.
24
24
 
25
+ Use `row()` for one labelled value or `row_group()` to align several values. Both accept `wrap=False` to keep values on one line and `wrap_width` to set the width used when wrapping. `row_group()` also accepts `label_colour`, `value_colour`, and `separator` as defaults for its rows:
26
+
27
+ ```python
28
+ from cli_style import row_group
29
+
30
+ print(row_group(
31
+ [{"label": "Command", "value": "python -m pip install lewishowles-cli-style"}],
32
+ wrap=False,
33
+ label_colour="muted",
34
+ ))
35
+ ```
36
+
37
+ Each row has `label`, `value`, and an optional `result`. It can override the group defaults with `label_colour`, `value_colour`, `separator`, `wrap`, or `wrap_width`. The JavaScript spellings, such as `labelColour` and `wrapWidth`, also work in row dictionaries. Callers add any blank lines around rendered blocks. Python `render()` removes trailing newlines from rendered strings.
38
+
25
39
  ## More information
26
40
 
27
41
  - [Repository](https://github.com/lewishowles/cli-style)
@@ -19,6 +19,7 @@ from ._wrappers import (
19
19
  next_step_block,
20
20
  panel,
21
21
  progress_bar,
22
+ quote_block,
22
23
  row,
23
24
  row_group,
24
25
  sparkline,
@@ -59,6 +60,7 @@ __all__ = [
59
60
  "next_step_block",
60
61
  "panel",
61
62
  "progress_bar",
63
+ "quote_block",
62
64
  "render",
63
65
  "resolve_binary",
64
66
  "row",
@@ -6,6 +6,22 @@ from typing import Any
6
6
 
7
7
  from .core import render
8
8
 
9
+
10
+ def _camel_case_row(row: Any) -> Any:
11
+ """Return a copy of a row with Python option names, such as label_colour, renamed to the camelCase names the renderer reads. Keys already in camelCase are kept, and a value that is not a dictionary is returned unchanged for the renderer to filter."""
12
+ if not isinstance(row, dict):
13
+ return row
14
+
15
+ converted_row = {}
16
+
17
+ for key, value in row.items():
18
+ parts = key.split("_")
19
+ camel_case_key = parts[0] + "".join(part.capitalize() for part in parts[1:])
20
+ converted_row[camel_case_key] = value
21
+
22
+ return converted_row
23
+
24
+
9
25
  def agent_transcript(
10
26
  entries: list[dict[str, Any]] | None = None,
11
27
  title: str | None = None,
@@ -334,6 +350,25 @@ def progress_bar(
334
350
  return render("progress-bar", data, **kwargs)
335
351
 
336
352
 
353
+ def quote_block(
354
+ lines: list[str] | None = None,
355
+ title: str | None = None,
356
+ width: int | float | None = None,
357
+ **kwargs: Any,
358
+ ) -> str:
359
+ """Return quote-block output; extra keyword arguments go to render()."""
360
+ data = {
361
+ "lines": [] if lines is None else lines,
362
+ }
363
+
364
+ if title is not None:
365
+ data["title"] = title
366
+ if width is not None:
367
+ data["width"] = width
368
+
369
+ return render("quote-block", data, **kwargs)
370
+
371
+
337
372
  def row(
338
373
  label: str,
339
374
  value: str,
@@ -342,6 +377,8 @@ def row(
342
377
  label_colour: str | None = None,
343
378
  value_colour: str | None = None,
344
379
  separator: str | None = None,
380
+ wrap: bool = True,
381
+ wrap_width: int | float | None = None,
345
382
  **kwargs: Any,
346
383
  ) -> str:
347
384
  """Return row output; extra keyword arguments go to render()."""
@@ -349,6 +386,7 @@ def row(
349
386
  "label": label,
350
387
  "value": value,
351
388
  "result": result,
389
+ "wrap": wrap,
352
390
  }
353
391
 
354
392
  if label_width is not None:
@@ -359,6 +397,8 @@ def row(
359
397
  data["valueColour"] = value_colour
360
398
  if separator is not None:
361
399
  data["separator"] = separator
400
+ if wrap_width is not None:
401
+ data["wrapWidth"] = wrap_width
362
402
 
363
403
  return render("row", data, **kwargs)
364
404
 
@@ -366,15 +406,29 @@ def row(
366
406
  def row_group(
367
407
  rows: list[dict[str, Any]],
368
408
  label_width: int | float | None = None,
409
+ label_colour: str | None = None,
410
+ value_colour: str | None = None,
411
+ separator: str | None = None,
412
+ wrap: bool = True,
413
+ wrap_width: int | float | None = None,
369
414
  **kwargs: Any,
370
415
  ) -> str:
371
416
  """Return row-group output; extra keyword arguments go to render()."""
372
417
  data = {
373
- "rows": rows,
418
+ "rows": [_camel_case_row(row) for row in rows],
419
+ "wrap": wrap,
374
420
  }
375
421
 
376
422
  if label_width is not None:
377
423
  data["labelWidth"] = label_width
424
+ if label_colour is not None:
425
+ data["labelColour"] = label_colour
426
+ if value_colour is not None:
427
+ data["valueColour"] = value_colour
428
+ if separator is not None:
429
+ data["separator"] = separator
430
+ if wrap_width is not None:
431
+ data["wrapWidth"] = wrap_width
378
432
 
379
433
  return render("row-group", data, **kwargs)
380
434
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lewishowles-cli-style"
3
- version = "0.16.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" }
@@ -382,6 +382,29 @@ extension CliStyle {
382
382
  return try render("progress-bar", data: data, options: options)
383
383
  }
384
384
 
385
+ /**
386
+ * Return quote-block output from the cli-style binary; `options` sets the profile, width, and binary.
387
+ */
388
+ public static func quoteBlock(
389
+ lines: [String]? = nil,
390
+ title: String? = nil,
391
+ width: Int? = nil,
392
+ options: CliStyleOptions = .init()
393
+ ) throws -> String {
394
+ var data: [String: Any] = [
395
+ "lines": lines ?? [],
396
+ ]
397
+
398
+ if let title = title {
399
+ data["title"] = title
400
+ }
401
+ if let width = width {
402
+ data["width"] = width
403
+ }
404
+
405
+ return try render("quote-block", data: data, options: options)
406
+ }
407
+
385
408
  /**
386
409
  * Return row output from the cli-style binary; `options` sets the profile, width, and binary.
387
410
  */
@@ -393,12 +416,15 @@ extension CliStyle {
393
416
  labelColour: String? = nil,
394
417
  valueColour: String? = nil,
395
418
  separator: String? = nil,
419
+ wrap: Bool = true,
420
+ wrapWidth: Int? = nil,
396
421
  options: CliStyleOptions = .init()
397
422
  ) throws -> String {
398
423
  var data: [String: Any] = [
399
424
  "label": label,
400
425
  "value": value,
401
426
  "result": result,
427
+ "wrap": wrap,
402
428
  ]
403
429
 
404
430
  if let labelWidth = labelWidth {
@@ -413,6 +439,9 @@ extension CliStyle {
413
439
  if let separator = separator {
414
440
  data["separator"] = separator
415
441
  }
442
+ if let wrapWidth = wrapWidth {
443
+ data["wrapWidth"] = wrapWidth
444
+ }
416
445
 
417
446
  return try render("row", data: data, options: options)
418
447
  }
@@ -423,15 +452,33 @@ extension CliStyle {
423
452
  public static func rowGroup(
424
453
  rows: [[String: Any]],
425
454
  labelWidth: Int? = nil,
455
+ labelColour: String? = nil,
456
+ valueColour: String? = nil,
457
+ separator: String? = nil,
458
+ wrap: Bool = true,
459
+ wrapWidth: Int? = nil,
426
460
  options: CliStyleOptions = .init()
427
461
  ) throws -> String {
428
462
  var data: [String: Any] = [
429
463
  "rows": rows,
464
+ "wrap": wrap,
430
465
  ]
431
466
 
432
467
  if let labelWidth = labelWidth {
433
468
  data["labelWidth"] = labelWidth
434
469
  }
470
+ if let labelColour = labelColour {
471
+ data["labelColour"] = labelColour
472
+ }
473
+ if let valueColour = valueColour {
474
+ data["valueColour"] = valueColour
475
+ }
476
+ if let separator = separator {
477
+ data["separator"] = separator
478
+ }
479
+ if let wrapWidth = wrapWidth {
480
+ data["wrapWidth"] = wrapWidth
481
+ }
435
482
 
436
483
  return try render("row-group", data: data, options: options)
437
484
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lewishowles/cli-style",
3
- "version": "0.16.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",
@@ -235,10 +235,15 @@ function renderWrapper(renderer) {
235
235
  (parameter) => !isRequiredParameter(parameter) && parameter.default === null,
236
236
  );
237
237
 
238
- const payloadLines = payloadParameters.map(
239
- (parameter) =>
240
- `\t\t${JSON.stringify(parameter.name)}: ${renderPayloadValue(parameter, descriptor)},`,
241
- );
238
+ const payloadLines = payloadParameters.map((parameter) => {
239
+ const isRowGroupRows = renderer.name === "row-group" && parameter.name === "rows";
240
+
241
+ const payloadValue = isRowGroupRows
242
+ ? "[_camel_case_row(row) for row in rows]"
243
+ : renderPayloadValue(parameter, descriptor);
244
+
245
+ return `\t\t${JSON.stringify(parameter.name)}: ${payloadValue},`;
246
+ });
242
247
 
243
248
  const nullablePayloadLines = nullableParameters.flatMap((parameter) => {
244
249
  const name = descriptor.parameterName(parameter);
@@ -279,6 +284,22 @@ function renderWrappers() {
279
284
  "",
280
285
  "from .core import render",
281
286
  "",
287
+ "",
288
+ "def _camel_case_row(row: Any) -> Any:",
289
+ '\t"""Return a copy of a row with Python option names, such as label_colour, renamed to the camelCase names the renderer reads. Keys already in camelCase are kept, and a value that is not a dictionary is returned unchanged for the renderer to filter."""',
290
+ "\tif not isinstance(row, dict):",
291
+ "\t\treturn row",
292
+ "",
293
+ "\tconverted_row = {}",
294
+ "",
295
+ "\tfor key, value in row.items():",
296
+ '\t\tparts = key.split("_")',
297
+ '\t\tcamel_case_key = parts[0] + "".join(part.capitalize() for part in parts[1:])',
298
+ "\t\tconverted_row[camel_case_key] = value",
299
+ "",
300
+ "\treturn converted_row",
301
+ "",
302
+ "",
282
303
  wrappers,
283
304
  "",
284
305
  ].join("\n");
@@ -635,6 +635,32 @@ const rendererDefinitions = [
635
635
  ],
636
636
  fixture: undefined,
637
637
  },
638
+ {
639
+ name: "quote-block",
640
+ category: "pattern",
641
+ api: "quoteBlock",
642
+ params: [
643
+ {
644
+ name: "lines",
645
+ type: "string[]",
646
+ required: false,
647
+ default: [],
648
+ },
649
+ {
650
+ name: "title",
651
+ type: "string",
652
+ required: false,
653
+ default: null,
654
+ },
655
+ {
656
+ name: "width",
657
+ type: "number",
658
+ required: false,
659
+ default: null,
660
+ },
661
+ ],
662
+ fixture: "quote-block",
663
+ },
638
664
  {
639
665
  name: "row",
640
666
  category: "primitive",
@@ -689,14 +715,12 @@ const rendererDefinitions = [
689
715
  type: "boolean",
690
716
  required: false,
691
717
  default: true,
692
- adapterOmit: true,
693
718
  },
694
719
  {
695
720
  name: "wrapWidth",
696
721
  type: "number",
697
722
  required: false,
698
723
  default: null,
699
- adapterOmit: true,
700
724
  },
701
725
  ],
702
726
  fixture: undefined,
@@ -719,19 +743,35 @@ const rendererDefinitions = [
719
743
  required: false,
720
744
  default: null,
721
745
  },
746
+ {
747
+ name: "labelColour",
748
+ type: "string",
749
+ required: false,
750
+ default: null,
751
+ },
752
+ {
753
+ name: "valueColour",
754
+ type: "string",
755
+ required: false,
756
+ default: null,
757
+ },
758
+ {
759
+ name: "separator",
760
+ type: "string",
761
+ required: false,
762
+ default: null,
763
+ },
722
764
  {
723
765
  name: "wrap",
724
766
  type: "boolean",
725
767
  required: false,
726
768
  default: true,
727
- adapterOmit: true,
728
769
  },
729
770
  {
730
771
  name: "wrapWidth",
731
772
  type: "number",
732
773
  required: false,
733
774
  default: null,
734
- adapterOmit: true,
735
775
  },
736
776
  ],
737
777
  fixture: undefined,
@@ -6,6 +6,7 @@ import { confirmationResult } from "../patterns/confirmation-result.js";
6
6
  import { diagnosticReport } from "../patterns/diagnostic-report.js";
7
7
  import { diffBlock } from "../patterns/diff-block.js";
8
8
  import { nextStepBlock } from "../patterns/next-step-block.js";
9
+ import { quoteBlock } from "../patterns/quote-block.js";
9
10
  import { taskSummary } from "../patterns/task-summary.js";
10
11
  import { barChart } from "../primitives/bar-chart.js";
11
12
  import { bulletList } from "../primitives/bullet-list.js";
@@ -78,6 +79,7 @@ const renderers = {
78
79
  width: input.width ?? options.width,
79
80
  }),
80
81
  "next-step-block": (input, options) => nextStepBlock(input, options),
82
+ "quote-block": (input, options) => quoteBlock(input, options),
81
83
  panel: (input, options) =>
82
84
  panel({
83
85
  ...input,