@lewishowles/cli-style 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.17.0 - 2026-10-03
6
+
7
+ ### Added
8
+
9
+ - 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.
10
+ - 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.
11
+
12
+ ## 0.16.0 - 2026-09-21
13
+
14
+ ### Breaking changes
15
+
16
+ - BREAKING: Removed the `panelWidth` option from `panel()` and the `panel` renderer. Use `width` instead.
17
+ - BREAKING: Panels now honour the requested total `width`, raise widths below 6 to the minimum usable width, and wrap long lines to fit.
18
+
19
+ ### Fixed
20
+
21
+ - An explicit JSON `width` now takes precedence over the terminal width for width-aware renderers, so piped output stays within the caller's requested bounds.
22
+
5
23
  ## 0.15.0 - 2026-09-20
6
24
 
7
25
  ### Added
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.
@@ -86,6 +88,10 @@ ui.print(output);
86
88
 
87
89
  Renderer methods return strings. `ui.print()`, `ui.write()`, and CLI commands handle stdout and stderr.
88
90
 
91
+ For `render` commands, width follows this priority: JSON payload `width`, `--width`,
92
+ `stdout.columns`, positive `COLUMNS`, then `80`, so piped output stays within the caller's requested
93
+ size when the payload supplies a width.
94
+
89
95
  ### CLI render
90
96
 
91
97
  Use `cli-style render <renderer>` when a script needs rendered text from JSON input:
@@ -276,6 +282,7 @@ Renderer names are stable for `cli-style render`, `cli_style_render`, and Python
276
282
  | `empty-state` | `emptyState(title, detail, options)` | Use `cli_style_render` | Use `render("empty-state", ...)` | Use `CliStyle.render` | Empty result message. |
277
283
  | `error-block` | `errorBlock(title, lines, options)` | Use `cli_style_render` | Use `render("error-block", ...)` | Use `CliStyle.render` | Structured error block. |
278
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. |
279
286
 
280
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.
281
288
 
@@ -313,6 +320,8 @@ Use these shapes with `cli-style render`, `cli_style_render`, or Python `render(
313
320
  - `result`: optional result type for a symbol and tone
314
321
  - `labelWidth`: optional minimum label width
315
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
316
325
 
317
326
  ### `row-group`
318
327
 
@@ -333,7 +342,10 @@ Use these shapes with `cli-style render`, `cli_style_render`, or Python `render(
333
342
 
334
343
  - `rows`: labelled values to align and render
335
344
  - `labelWidth`: optional minimum width for every label
336
- - 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
337
349
 
338
350
  ### `bullet-list`
339
351
 
@@ -383,19 +395,19 @@ Pre-render a `span` when only part of the hint needs emphasis.
383
395
 
384
396
  ### Structured primitives
385
397
 
386
- | Renderer | Main fields |
387
- | --------------- | -------------------------------------- |
388
- | `divider` | `label`, `dividerWidth`, `character` |
389
- | `chip` | `label`, `tone` |
390
- | `panel` | `title`, `lines`, `tone`, `panelWidth` |
391
- | `table` | `columns`, `rows`, `width` |
392
- | `progress-bar` | `value`, `max`, `barWidth`, `tone` |
393
- | `bar-chart` | `rows`, `barWidth` |
394
- | `sparkline` | `values`, `label`, `tone`, `width` |
395
- | `step` | `label`, `state` |
396
- | `step-progress` | `steps`, `current` |
397
- | `empty-state` | `title`, `detail` |
398
- | `error-block` | `title`, `lines` |
398
+ | Renderer | Main fields |
399
+ | --------------- | ------------------------------------ |
400
+ | `divider` | `label`, `dividerWidth`, `character` |
401
+ | `chip` | `label`, `tone` |
402
+ | `panel` | `title`, `lines`, `tone`, `width` |
403
+ | `table` | `columns`, `rows`, `width` |
404
+ | `progress-bar` | `value`, `max`, `barWidth`, `tone` |
405
+ | `bar-chart` | `rows`, `barWidth` |
406
+ | `sparkline` | `values`, `label`, `tone`, `width` |
407
+ | `step` | `label`, `state` |
408
+ | `step-progress` | `steps`, `current` |
409
+ | `empty-state` | `title`, `detail` |
410
+ | `error-block` | `title`, `lines` |
399
411
 
400
412
  For visual examples, run `cli-style gallery --section primitives`.
401
413
 
@@ -443,6 +455,19 @@ Patterns are built-in composite renderers for common report shapes. They use the
443
455
  | `next-step-block` | `nextStepBlock(nextStep, options)` | Next-step guidance. | `title`, `next`, `reason`, `commands`, `alternatives` |
444
456
  | `compact-data-table` | `compactDataTable(data, options)` | Titled compact data table. | `title`, `summary`, `columns`, `rows` |
445
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
+ ```
446
471
 
447
472
  ### `diff-block`
448
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,
@@ -297,7 +313,7 @@ def panel(
297
313
  lines: list[str] | None = None,
298
314
  title: str | None = None,
299
315
  tone: str = "info",
300
- panel_width: int | float | None = None,
316
+ width: int | float | None = None,
301
317
  **kwargs: Any,
302
318
  ) -> str:
303
319
  """Return panel output; extra keyword arguments go to render()."""
@@ -308,8 +324,8 @@ def panel(
308
324
 
309
325
  if title is not None:
310
326
  data["title"] = title
311
- if panel_width is not None:
312
- data["panelWidth"] = panel_width
327
+ if width is not None:
328
+ data["width"] = width
313
329
 
314
330
  return render("panel", data, **kwargs)
315
331
 
@@ -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.15.0"
3
+ version = "0.17.0"
4
4
  description = "Python adapter for the shared cli-style binary."
5
5
  authors = [{ name = "Lewis Howles" }]
6
6
  license = { text = "MIT" }
@@ -341,7 +341,7 @@ extension CliStyle {
341
341
  lines: [String]? = nil,
342
342
  title: String? = nil,
343
343
  tone: String = "info",
344
- panelWidth: Int? = nil,
344
+ width: Int? = nil,
345
345
  options: CliStyleOptions = .init()
346
346
  ) throws -> String {
347
347
  var data: [String: Any] = [
@@ -352,8 +352,8 @@ extension CliStyle {
352
352
  if let title = title {
353
353
  data["title"] = title
354
354
  }
355
- if let panelWidth = panelWidth {
356
- data["panelWidth"] = panelWidth
355
+ if let width = width {
356
+ data["width"] = width
357
357
  }
358
358
 
359
359
  return try render("panel", data: data, options: options)
@@ -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.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Shared terminal output styles for Lewis Howles tools, diagnostics, and package CLIs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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");
@@ -29,7 +29,7 @@ export function resolveTerminalCapabilities(options = {}) {
29
29
  const isCi = env.CI === "true" || env.CI === "1";
30
30
  const isDumb = env.TERM === "dumb";
31
31
  const forceColour = env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "0";
32
- const width = resolveWidth(stdout.columns);
32
+ const width = resolveWidth(stdout.columns, env.COLUMNS);
33
33
  const usesPlain = argv.includes("--plain");
34
34
 
35
35
  const colour = resolveColour({
@@ -189,16 +189,25 @@ function resolveUnicode(options) {
189
189
  }
190
190
 
191
191
  /**
192
- * Resolve terminal width with a stable fallback.
192
+ * Resolve the terminal width from the output stream, then `COLUMNS`, then a fixed fallback.
193
193
  *
194
- * @param {number} width
195
- * Stream-reported column count.
194
+ * @param {number} streamWidth
195
+ * The column count the output stream reports when it is a terminal.
196
+ * @param {string} columns
197
+ * The `COLUMNS` environment value, used when the stream reports no width, such as when
198
+ * output is piped. Anything other than a positive whole number is ignored.
196
199
  * @returns {number}
197
200
  * Usable terminal width.
198
201
  */
199
- function resolveWidth(width) {
200
- if (Number.isInteger(width) && width > 0) {
201
- return width;
202
+ function resolveWidth(streamWidth, columns) {
203
+ if (Number.isInteger(streamWidth) && streamWidth > 0) {
204
+ return streamWidth;
205
+ }
206
+
207
+ const environmentWidth = Number(columns);
208
+
209
+ if (Number.isInteger(environmentWidth) && environmentWidth > 0) {
210
+ return environmentWidth;
202
211
  }
203
212
 
204
213
  return defaultWidth;
@@ -595,7 +595,7 @@ const rendererDefinitions = [
595
595
  default: "info",
596
596
  },
597
597
  {
598
- name: "panelWidth",
598
+ name: "width",
599
599
  type: "number",
600
600
  required: false,
601
601
  default: null,
@@ -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";
@@ -38,6 +39,7 @@ const renderers = {
38
39
  bulletList(input.items, {
39
40
  ...input,
40
41
  ...options,
42
+ width: input.width ?? options.width,
41
43
  }),
42
44
  chip: (input, options) =>
43
45
  chip(input.label, input.tone, {
@@ -63,6 +65,7 @@ const renderers = {
63
65
  errorBlock(input.title, input.lines, {
64
66
  ...input,
65
67
  ...options,
68
+ width: input.width ?? options.width,
66
69
  }),
67
70
  hint: (input, options) =>
68
71
  hint(input.message, {
@@ -73,12 +76,15 @@ const renderers = {
73
76
  labelledLine(input.label, input.message, {
74
77
  ...input,
75
78
  ...options,
79
+ width: input.width ?? options.width,
76
80
  }),
77
81
  "next-step-block": (input, options) => nextStepBlock(input, options),
82
+ "quote-block": (input, options) => quoteBlock(input, options),
78
83
  panel: (input, options) =>
79
84
  panel({
80
85
  ...input,
81
86
  ...options,
87
+ width: input.width ?? options.width,
82
88
  }),
83
89
  "progress-bar": (input, options) =>
84
90
  progressBar({
@@ -89,16 +95,19 @@ const renderers = {
89
95
  row(input.label, input.value, {
90
96
  ...input,
91
97
  ...options,
98
+ width: input.width ?? options.width,
92
99
  }),
93
100
  "row-group": (input, options) =>
94
101
  rowGroup({
95
102
  ...input,
96
103
  ...options,
104
+ width: input.width ?? options.width,
97
105
  }),
98
106
  sparkline: (input, options) =>
99
107
  sparkline({
100
108
  ...input,
101
109
  ...options,
110
+ width: input.width ?? options.width,
102
111
  }),
103
112
  span: (input, options) =>
104
113
  span(input.value, input.tone, {
@@ -124,6 +133,7 @@ const renderers = {
124
133
  table({
125
134
  ...input,
126
135
  ...options,
136
+ width: input.width ?? options.width,
127
137
  }),
128
138
  "task-summary": (input, options) => taskSummary(input, options),
129
139
  };
@@ -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 { stripAnsi } from "../formatters/ansi.js";
11
12
  import { renderSpinnerFrame } from "../interactive/spinner.js";
@@ -133,6 +134,7 @@ export const galleryFixtures = [
133
134
  "confirmation-result",
134
135
  "next-step-block",
135
136
  "diff-block",
137
+ "quote-block",
136
138
  "sparkline",
137
139
  "spinner",
138
140
  ];
@@ -280,7 +282,6 @@ function galleryWidthOptions(width) {
280
282
  return {
281
283
  barWidth: Math.max(width - 18, 1),
282
284
  dividerWidth: width,
283
- panelWidth: width,
284
285
  };
285
286
  }
286
287
 
@@ -601,6 +602,19 @@ function renderPatterns(options, fixture) {
601
602
  "diagnostic-report": ["Diagnostic report", report],
602
603
  "diff-block": ["Diff block", diff],
603
604
  "next-step-block": ["Next-step block", nextStep],
605
+ "quote-block": [
606
+ "Quote block",
607
+ quoteBlock(
608
+ {
609
+ lines: [
610
+ "A command can return a long explanation that wraps while the marker stays beside each continuation line.",
611
+ "A second line keeps the same marker.",
612
+ ],
613
+ title: "Command output",
614
+ },
615
+ options,
616
+ ),
617
+ ],
604
618
  reporter: ["Reporter", reporterOutput],
605
619
  "task-summary": ["Task summary", summary],
606
620
  };
@@ -636,6 +650,9 @@ function renderPatterns(options, fixture) {
636
650
  "Diff block",
637
651
  frameExample(diff, options),
638
652
  "",
653
+ "Quote block",
654
+ frameExample(fixtures["quote-block"][1], options),
655
+ "",
639
656
  "Compact data table",
640
657
  frameExample(compactTable, options),
641
658
  "",
@@ -790,7 +807,7 @@ function renderPrimitives(options) {
790
807
  emptyState("No results", "tone: muted", options),
791
808
  errorBlock("Failed", ["tone: danger"], {
792
809
  ...options,
793
- panelWidth: 40,
810
+ width: 40,
794
811
  }),
795
812
  "",
796
813
  "Steps",
package/src/index.js CHANGED
@@ -38,6 +38,7 @@ export { confirmationResult } from "./patterns/confirmation-result.js";
38
38
  export { diagnosticReport } from "./patterns/diagnostic-report.js";
39
39
  export { diffBlock } from "./patterns/diff-block.js";
40
40
  export { nextStepBlock } from "./patterns/next-step-block.js";
41
+ export { quoteBlock } from "./patterns/quote-block.js";
41
42
  export { taskSummary } from "./patterns/task-summary.js";
42
43
  export { barChart } from "./primitives/bar-chart.js";
43
44
  export { bulletList } from "./primitives/bullet-list.js";
@@ -0,0 +1,70 @@
1
+ import { foreground, stripAnsi } from "../formatters/ansi.js";
2
+ import { wrapText } from "../formatters/wrap.js";
3
+ import { profiles } from "../profiles/profiles.js";
4
+ import { isNonEmptyString, isRecord, renderSection } from "./helpers.js";
5
+
6
+ // The total width, including the marker and its space, used when no width is given.
7
+ const defaultWidth = 66;
8
+
9
+ /**
10
+ * Render another program's output with a marker beside each line, under an optional heading.
11
+ *
12
+ * @param {object} quote
13
+ * Quoted output to show.
14
+ * @param {string[]} quote.lines
15
+ * Output lines, including any blank lines to preserve.
16
+ * @param {string} quote.title
17
+ * Optional heading above the output.
18
+ * @param {number} quote.width
19
+ * Optional total width, including the marker and its space.
20
+ * @param {object} options
21
+ * Rendering options, such as the profile, Unicode support, and a fallback width.
22
+ * @returns {object|string}
23
+ * The quote unchanged for the json profile. Otherwise the marked lines as one string,
24
+ * or an empty string when there are no lines to show.
25
+ */
26
+ export function quoteBlock(quote, options = {}) {
27
+ if (options.profile === profiles.JSON) {
28
+ return quote;
29
+ }
30
+
31
+ if (!isRecord(quote) || !Array.isArray(quote.lines)) {
32
+ return "";
33
+ }
34
+
35
+ // Lines that are not strings are skipped so one bad entry does not hide the rest.
36
+ const lines = quote.lines.filter((line) => typeof line === "string");
37
+
38
+ if (lines.length === 0) {
39
+ return "";
40
+ }
41
+
42
+ // The width from the quote wins over the options, and an invalid width falls back to the default.
43
+ const requestedWidth = quote.width ?? options.width ?? defaultWidth;
44
+
45
+ const width =
46
+ Number.isInteger(requestedWidth) && requestedWidth > 0 ? requestedWidth : defaultWidth;
47
+
48
+ // The room left for text once the marker and its space are drawn.
49
+ const contentWidth = Math.max(width - 2, 1);
50
+
51
+ // Plain and CI output are written for logs and searches, so they get no colour and the ASCII marker.
52
+ const isPlainOutput = options.profile === profiles.CI || options.profile === profiles.PLAIN;
53
+
54
+ // The ASCII marker is also used whenever Unicode is off.
55
+ const marker = options.unicode === false || isPlainOutput ? "|" : "│";
56
+
57
+ // The marker is muted wherever colour is allowed.
58
+ const renderedMarker = isPlainOutput ? marker : foreground(marker, "muted", options);
59
+
60
+ // Each wrapped part gets its own marker, so continuation lines stay visibly quoted.
61
+ const renderedLines = lines.flatMap((line) =>
62
+ wrapText(stripAnsi(line), contentWidth).map((part) => `${renderedMarker} ${part}`),
63
+ );
64
+
65
+ if (!isNonEmptyString(quote.title)) {
66
+ return renderedLines.join("\n");
67
+ }
68
+
69
+ return renderSection(stripAnsi(quote.title), renderedLines, options, false);
70
+ }
@@ -1,10 +1,14 @@
1
1
  import { background, foreground, style } from "../formatters/ansi.js";
2
+ import { wrapText } from "../formatters/wrap.js";
2
3
  import { profiles } from "../profiles/profiles.js";
3
4
  import { panelColours } from "../theme/colours.js";
4
5
 
5
- // Panels use a stable width so grouped output aligns across a report.
6
+ // The width used when no width is given, such as a direct library call outside a terminal.
6
7
  const defaultWidth = 40;
7
8
 
9
+ // The narrowest panel that still leaves one column for content beside the accent and padding.
10
+ const minimumWidth = 6;
11
+
8
12
  // Info is the neutral semantic accent for grouped explanatory content.
9
13
  const defaultTone = "info";
10
14
 
@@ -17,8 +21,9 @@ const defaultTone = "info";
17
21
  * Whether ANSI colour should be applied.
18
22
  * @param {string[]} options.lines
19
23
  * Content lines to render.
20
- * @param {number} options.panelWidth
21
- * Total panel width.
24
+ * @param {number} options.width
25
+ * Total panel width, including the accent and padding. Widths below 6 are raised to 6,
26
+ * which leaves room for one character of content. Longer lines wrap to fit.
22
27
  * @param {string} options.profile
23
28
  * Active output profile.
24
29
  * @param {string} options.title
@@ -37,18 +42,18 @@ export function panel(options = {}) {
37
42
  return [...(options.title === undefined ? [] : [options.title]), ...lines].join("\n");
38
43
  }
39
44
 
40
- const width = Math.max(options.panelWidth ?? defaultWidth, 4);
45
+ const width = Math.max(options.width ?? defaultWidth, minimumWidth);
41
46
  const panelLines = ["", ...(options.title === undefined ? [] : [options.title]), ...lines, ""];
42
47
 
43
48
  return panelLines
44
- .map((line, index) =>
49
+ .flatMap((line, index) =>
45
50
  renderLine(line, width, index === 1 && options.title !== undefined, options),
46
51
  )
47
52
  .join("\n");
48
53
  }
49
54
 
50
55
  /**
51
- * Render one fixed-width panel line.
56
+ * Render one content line, wrapped across as many panel lines as it needs.
52
57
  *
53
58
  * @param {string} line
54
59
  * Content line.
@@ -58,28 +63,30 @@ export function panel(options = {}) {
58
63
  * Whether the line is the panel title.
59
64
  * @param {object} options
60
65
  * Rendering options.
61
- * @returns {string}
62
- * Rendered content line.
66
+ * @returns {string[]}
67
+ * The panel lines, each exactly `width` columns wide.
63
68
  */
64
69
  function renderLine(line, width, isTitle, options) {
65
70
  const accent = options.unicode === false ? "|" : "▌";
66
71
  const contentWidth = width - 5;
67
- const visibleLine = line.slice(0, contentWidth);
68
- const content = ` ${visibleLine.padEnd(contentWidth, " ")} `;
69
72
 
70
- if (options.colour !== true) {
71
- return `${accent}${content}`;
72
- }
73
+ return wrapText(line, contentWidth).map((visibleLine) => {
74
+ const content = ` ${visibleLine.padEnd(contentWidth, " ")} `;
75
+
76
+ if (options.colour !== true) {
77
+ return `${accent}${content}`;
78
+ }
73
79
 
74
- const renderedAccent = renderPanelColour(accent, options);
80
+ const renderedAccent = renderPanelColour(accent, options);
75
81
 
76
- const renderedContent = foreground(
77
- background(content, panelColours.background, options),
78
- panelColours.body,
79
- options,
80
- );
82
+ const renderedContent = foreground(
83
+ background(content, panelColours.background, options),
84
+ panelColours.body,
85
+ options,
86
+ );
81
87
 
82
- return `${renderedAccent}${isTitle ? style(renderedContent, "bold", options) : renderedContent}`;
88
+ return `${renderedAccent}${isTitle ? style(renderedContent, "bold", options) : renderedContent}`;
89
+ });
83
90
  }
84
91
 
85
92
  /**