@promptctl/rich-js 0.17.0 → 0.18.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.
Files changed (147) hide show
  1. package/README.md +13 -26
  2. package/dist/core/anchor.d.ts +45 -0
  3. package/dist/core/anchor.js +39 -0
  4. package/dist/core/ansi.d.ts +65 -0
  5. package/dist/core/ansi.js +231 -0
  6. package/dist/core/box.d.ts +0 -1
  7. package/dist/core/box.js +6 -18
  8. package/dist/core/cells.d.ts +16 -5
  9. package/dist/core/cells.js +31 -10
  10. package/dist/core/color.d.ts +4 -8
  11. package/dist/core/color.js +28 -19
  12. package/dist/core/console.d.ts +13 -4
  13. package/dist/core/console.js +91 -36
  14. package/dist/core/export-html.d.ts +40 -5
  15. package/dist/core/export-html.js +111 -17
  16. package/dist/core/markup.d.ts +27 -5
  17. package/dist/core/markup.js +282 -170
  18. package/dist/core/measure.d.ts +1 -1
  19. package/dist/core/measure.js +2 -1
  20. package/dist/core/place.d.ts +30 -0
  21. package/dist/core/place.js +51 -0
  22. package/dist/core/pretty.d.ts +3 -0
  23. package/dist/core/pretty.js +5 -1
  24. package/dist/core/protocol.d.ts +136 -6
  25. package/dist/core/protocol.js +64 -2
  26. package/dist/core/render.d.ts +2 -2
  27. package/dist/core/render.js +2 -2
  28. package/dist/core/segment.d.ts +23 -4
  29. package/dist/core/segment.js +75 -16
  30. package/dist/core/spinnerData.d.ts +2 -0
  31. package/dist/core/spinnerData.js +3 -1
  32. package/dist/core/strip.d.ts +3 -7
  33. package/dist/core/strip.js +32 -35
  34. package/dist/core/style.d.ts +25 -0
  35. package/dist/core/style.js +61 -89
  36. package/dist/core/text.d.ts +16 -15
  37. package/dist/core/text.js +62 -76
  38. package/dist/host/app.d.ts +121 -0
  39. package/dist/host/app.js +255 -0
  40. package/dist/host/host-environment.d.ts +26 -0
  41. package/dist/host/host-environment.js +44 -0
  42. package/dist/host/index.d.ts +4 -2
  43. package/dist/host/index.js +2 -1
  44. package/dist/host/terminal-host.d.ts +43 -7
  45. package/dist/host/terminal-host.js +57 -3
  46. package/dist/index.d.ts +7 -3
  47. package/dist/index.js +4 -1
  48. package/dist/node/terminal-host.d.ts +31 -2
  49. package/dist/node/terminal-host.js +69 -2
  50. package/dist/node/traceback.d.ts +16 -6
  51. package/dist/node/traceback.js +30 -8
  52. package/dist/renderables/align.d.ts +2 -2
  53. package/dist/renderables/align.js +6 -19
  54. package/dist/renderables/columns.js +4 -20
  55. package/dist/renderables/embed.d.ts +50 -0
  56. package/dist/renderables/embed.js +71 -0
  57. package/dist/renderables/group.js +3 -1
  58. package/dist/renderables/layout.js +31 -34
  59. package/dist/renderables/live.d.ts +6 -3
  60. package/dist/renderables/live.js +89 -45
  61. package/dist/renderables/markdown.d.ts +1 -1
  62. package/dist/renderables/markdown.js +6 -4
  63. package/dist/renderables/padding.js +3 -2
  64. package/dist/renderables/panel.js +26 -41
  65. package/dist/renderables/progress.js +5 -8
  66. package/dist/renderables/progressBar.js +6 -5
  67. package/dist/renderables/rule.d.ts +3 -2
  68. package/dist/renderables/rule.js +9 -9
  69. package/dist/renderables/spinner.d.ts +5 -3
  70. package/dist/renderables/spinner.js +14 -8
  71. package/dist/renderables/syntax.d.ts +1 -1
  72. package/dist/renderables/syntax.js +2 -1
  73. package/dist/renderables/table.d.ts +26 -14
  74. package/dist/renderables/table.js +239 -141
  75. package/dist/renderables/traceback.js +8 -9
  76. package/dist/renderables/tree.d.ts +17 -3
  77. package/dist/renderables/tree.js +62 -71
  78. package/dist/renderables/viewport.d.ts +114 -0
  79. package/dist/renderables/viewport.js +198 -0
  80. package/dist/template-bindings/color-funcs.js +11 -13
  81. package/dist/template-bindings/index.js +4 -2
  82. package/dist/template-bindings/palette-funcs.js +6 -16
  83. package/dist/template-bindings/style-funcs.js +4 -0
  84. package/dist/themes/data/ansi-tables.d.ts +14 -0
  85. package/dist/themes/data/ansi-tables.js +49 -0
  86. package/dist/themes/data/atom-one-dark.js +19 -0
  87. package/dist/themes/data/atom-one-light.js +19 -0
  88. package/dist/themes/data/catppuccin-frappe.js +19 -0
  89. package/dist/themes/data/catppuccin-latte.js +19 -0
  90. package/dist/themes/data/catppuccin-macchiato.js +19 -0
  91. package/dist/themes/data/catppuccin-mocha.js +19 -0
  92. package/dist/themes/data/cyberpunk.js +2 -0
  93. package/dist/themes/data/default.js +2 -0
  94. package/dist/themes/data/dracula.js +19 -0
  95. package/dist/themes/data/flexoki.js +19 -0
  96. package/dist/themes/data/gruvbox.js +19 -0
  97. package/dist/themes/data/monokai.js +19 -0
  98. package/dist/themes/data/nord.js +19 -0
  99. package/dist/themes/data/rose-pine-dawn.js +19 -0
  100. package/dist/themes/data/rose-pine-moon.js +19 -0
  101. package/dist/themes/data/rose-pine.js +19 -0
  102. package/dist/themes/data/solarized-dark.js +19 -0
  103. package/dist/themes/data/solarized-light.js +19 -0
  104. package/dist/themes/data/svg-export.js +19 -0
  105. package/dist/themes/data/textual-ansi.js +2 -0
  106. package/dist/themes/data/textual-dark.js +2 -0
  107. package/dist/themes/data/textual-light.js +2 -0
  108. package/dist/themes/data/tokyo-night.js +19 -0
  109. package/dist/themes/data/types.d.ts +16 -0
  110. package/dist/themes/data/types.js +10 -1
  111. package/dist/themes/registry.d.ts +3 -2
  112. package/dist/themes/registry.js +13 -1
  113. package/dist/themes/terminalThemes.d.ts +2 -7
  114. package/dist/themes/terminalThemes.js +4 -9
  115. package/dist/widgets/button.d.ts +1 -1
  116. package/dist/widgets/button.js +4 -2
  117. package/dist/widgets/checkbox.d.ts +2 -2
  118. package/dist/widgets/checkbox.js +5 -3
  119. package/dist/widgets/dropdown.d.ts +2 -2
  120. package/dist/widgets/dropdown.js +13 -16
  121. package/dist/widgets/event-router.d.ts +21 -28
  122. package/dist/widgets/event-router.js +80 -73
  123. package/dist/widgets/focus-manager.d.ts +25 -6
  124. package/dist/widgets/focus-manager.js +63 -65
  125. package/dist/widgets/hit.d.ts +64 -0
  126. package/dist/widgets/hit.js +113 -0
  127. package/dist/widgets/index.d.ts +5 -7
  128. package/dist/widgets/index.js +3 -4
  129. package/dist/widgets/slider.d.ts +2 -2
  130. package/dist/widgets/slider.js +7 -7
  131. package/dist/widgets/static-item.d.ts +9 -15
  132. package/dist/widgets/static-item.js +9 -15
  133. package/dist/widgets/text-input.d.ts +2 -2
  134. package/dist/widgets/text-input.js +17 -14
  135. package/dist/widgets/toggle.d.ts +1 -1
  136. package/dist/widgets/toggle.js +3 -2
  137. package/dist/widgets/types.d.ts +14 -35
  138. package/dist/widgets/types.js +0 -1
  139. package/dist/widgets/widget-app.d.ts +63 -0
  140. package/dist/widgets/widget-app.js +199 -0
  141. package/dist/widgets/widget-base.d.ts +4 -6
  142. package/dist/widgets/widget-base.js +22 -30
  143. package/package.json +12 -4
  144. package/dist/host/host-stream.d.ts +0 -30
  145. package/dist/host/host-stream.js +0 -35
  146. package/dist/widgets/screen.d.ts +0 -98
  147. package/dist/widgets/screen.js +0 -530
@@ -3,25 +3,9 @@
3
3
  */
4
4
  import { Segment } from "../core/segment.js";
5
5
  import { Measurement } from "../core/measure.js";
6
- import { RichText } from "../core/text.js";
7
- import { isMeasurable, withBoundedWidth, withCellWidth } from "../core/protocol.js";
6
+ import { isMeasurable, stackedHeight, withBoundedWidth, withCellWidth } from "../core/protocol.js";
8
7
  import { cellCount } from "../core/cells.js";
9
- function toRenderable(item) {
10
- // [LAW:single-enforcer] A `RichText` passed directly implements `render`
11
- // and would otherwise leave through the passthrough arm below untouched,
12
- // keeping its default `end: "\n"` (rich-text-5ai) — the same gap the code
13
- // review found in table.ts's `toRenderable`. A column item is a row
14
- // fragment, not a printed unit, same as every other embedding site.
15
- if (item instanceof RichText) {
16
- const copy = item.copy();
17
- copy.end = "";
18
- return copy;
19
- }
20
- if (typeof item === "object" && item !== null && "render" in item) {
21
- return item;
22
- }
23
- return new RichText(String(item ?? ""), { end: "" });
24
- }
8
+ import { embed } from "./embed.js";
25
9
  export class Columns {
26
10
  renderables;
27
11
  expand;
@@ -30,7 +14,7 @@ export class Columns {
30
14
  columnFirst;
31
15
  gutterWidth;
32
16
  constructor(items, options) {
33
- this.renderables = items ? [...items].map(toRenderable) : [];
17
+ this.renderables = items ? [...items].map(embed) : [];
34
18
  this.expand = options?.expand ?? false;
35
19
  this.equal = options?.equal ?? false;
36
20
  // A declared column width is a cell count like any other, and it reaches
@@ -70,7 +54,7 @@ export class Columns {
70
54
  const lines = item === undefined
71
55
  ? []
72
56
  : Segment.splitLines([
73
- ...item.render({ ...options, maxWidth: width }),
57
+ ...item.render({ ...options, maxWidth: width, height: stackedHeight(options.height) }),
74
58
  ]);
75
59
  return { lines, width };
76
60
  });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The one crossing where content a caller hands a renderable — a table cell, a
3
+ * panel's body, title or subtitle, a tree label, a column item, a layout pane,
4
+ * a rule's title — becomes something that renderable can lay out.
5
+ * [LAW:single-enforcer]
6
+ *
7
+ * Each of those sites used to build its own `RichText` straight from the
8
+ * constructor, which does not parse markup, so `[red]Solo[/red]` reached the
9
+ * terminal with its tags intact. Table cells were fixed on their own first; the
10
+ * rest kept the defect until the rule moved here.
11
+ *
12
+ * Markup is parsed because Rich parses it: every one of these positions
13
+ * reaches the wire through Rich's `render_str`, and a console's markup is on by
14
+ * default. Rich's `render_str` also honours `Console(markup=False)` and runs the
15
+ * console's highlighter; this crossing does neither, because the console's
16
+ * settings do not reach it (rich-markup-3sw).
17
+ *
18
+ * `end` is cleared because embedded text is a fragment rather than a printed
19
+ * line; left at the default `"\n"` it draws a trailing blank row. A `RichText`
20
+ * is copied first, since clearing in place would reach back into the caller's
21
+ * object.
22
+ */
23
+ import type { Measurable, Renderable, RenderOptions } from "../core/protocol.js";
24
+ import { Segment } from "../core/segment.js";
25
+ import type { Style } from "../core/style.js";
26
+ import { RichText } from "../core/text.js";
27
+ /**
28
+ * Caller content as the text an embedding site lays out, `end` cleared. A
29
+ * string is the only kind of content that can contain markup, so it is the only
30
+ * kind parsed; any other value is its `String` form as written, so an object's
31
+ * `[object Object]` is not eaten as a tag.
32
+ */
33
+ export declare function embeddedText(content: unknown): RichText;
34
+ /**
35
+ * Caller content as something an embedding site can render. A non-text
36
+ * `Renderable` (a nested `Panel` or `Table`) passes through: it carries no
37
+ * `end` to clear and no markup to parse. Everything else is `embeddedText`.
38
+ */
39
+ export declare function embed(content: unknown): Renderable & Partial<Measurable>;
40
+ /**
41
+ * Caller content set into a line it shares with other drawing — a panel's
42
+ * title or subtitle in its border, a rule's title — as one line of segments:
43
+ * a space either side, its own styles over `base`. The caller cuts it to the
44
+ * room it has with `Segment.adjustLineLength`, which is cell-aware.
45
+ *
46
+ * Content with no text is no label at all, not two spaces: an absent title, an
47
+ * empty string, an empty `RichText` and markup that styles nothing all draw
48
+ * the plain line, as Rich's do (an empty `Text` is falsy there).
49
+ */
50
+ export declare function inlineLabel(content: unknown, options: RenderOptions, base: Style | undefined): Segment[];
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The one crossing where content a caller hands a renderable — a table cell, a
3
+ * panel's body, title or subtitle, a tree label, a column item, a layout pane,
4
+ * a rule's title — becomes something that renderable can lay out.
5
+ * [LAW:single-enforcer]
6
+ *
7
+ * Each of those sites used to build its own `RichText` straight from the
8
+ * constructor, which does not parse markup, so `[red]Solo[/red]` reached the
9
+ * terminal with its tags intact. Table cells were fixed on their own first; the
10
+ * rest kept the defect until the rule moved here.
11
+ *
12
+ * Markup is parsed because Rich parses it: every one of these positions
13
+ * reaches the wire through Rich's `render_str`, and a console's markup is on by
14
+ * default. Rich's `render_str` also honours `Console(markup=False)` and runs the
15
+ * console's highlighter; this crossing does neither, because the console's
16
+ * settings do not reach it (rich-markup-3sw).
17
+ *
18
+ * `end` is cleared because embedded text is a fragment rather than a printed
19
+ * line; left at the default `"\n"` it draws a trailing blank row. A `RichText`
20
+ * is copied first, since clearing in place would reach back into the caller's
21
+ * object.
22
+ */
23
+ import { renderMarkup } from "../core/markup.js";
24
+ import { Segment } from "../core/segment.js";
25
+ import { RichText } from "../core/text.js";
26
+ /**
27
+ * Caller content as the text an embedding site lays out, `end` cleared. A
28
+ * string is the only kind of content that can contain markup, so it is the only
29
+ * kind parsed; any other value is its `String` form as written, so an object's
30
+ * `[object Object]` is not eaten as a tag.
31
+ */
32
+ export function embeddedText(content) {
33
+ const text = content instanceof RichText
34
+ ? content.copy()
35
+ : typeof content === "string"
36
+ ? renderMarkup(content)
37
+ : new RichText(String(content ?? ""));
38
+ text.end = "";
39
+ return text;
40
+ }
41
+ /**
42
+ * Caller content as something an embedding site can render. A non-text
43
+ * `Renderable` (a nested `Panel` or `Table`) passes through: it carries no
44
+ * `end` to clear and no markup to parse. Everything else is `embeddedText`.
45
+ */
46
+ export function embed(content) {
47
+ if (!(content instanceof RichText) && typeof content === "object" && content !== null && "render" in content) {
48
+ return content;
49
+ }
50
+ return embeddedText(content);
51
+ }
52
+ /**
53
+ * Caller content set into a line it shares with other drawing — a panel's
54
+ * title or subtitle in its border, a rule's title — as one line of segments:
55
+ * a space either side, its own styles over `base`. The caller cuts it to the
56
+ * room it has with `Segment.adjustLineLength`, which is cell-aware.
57
+ *
58
+ * Content with no text is no label at all, not two spaces: an absent title, an
59
+ * empty string, an empty `RichText` and markup that styles nothing all draw
60
+ * the plain line, as Rich's do (an empty `Text` is falsy there).
61
+ */
62
+ export function inlineLabel(content, options, base) {
63
+ // The label leaves at its natural width, `"ignore"`, because the caller does
64
+ // the cutting; the label's own overflow method would cut it first, and to a
65
+ // width no one measured. `"ignore"` also leaves it unjustified, so its spaces
66
+ // stay where `pad` put them.
67
+ const bare = embeddedText(content);
68
+ const text = bare.plain === "" ? bare : bare.pad(1);
69
+ text.overflow = "ignore";
70
+ return [...Segment.applyStyle(text.render(options), base)];
71
+ }
@@ -2,14 +2,16 @@
2
2
  * Group — renders multiple renderables in sequence.
3
3
  * No visual chrome — purely a container.
4
4
  */
5
+ import { stackedHeight } from "../core/protocol.js";
5
6
  export class Group {
6
7
  renderables;
7
8
  constructor(...renderables) {
8
9
  this.renderables = renderables;
9
10
  }
10
11
  *render(options) {
12
+ const memberOptions = { ...options, height: stackedHeight(options.height) };
11
13
  for (const renderable of this.renderables) {
12
- yield* renderable.render(options);
14
+ yield* renderable.render(memberOptions);
13
15
  }
14
16
  }
15
17
  }
@@ -2,8 +2,8 @@
2
2
  * Layout — divides the screen into rectangular regions.
3
3
  */
4
4
  import { Segment } from "../core/segment.js";
5
- import { RichText } from "../core/text.js";
6
- import { isMeasurable, withBoundedWidth, withCellWidth } from "../core/protocol.js";
5
+ import { embed } from "./embed.js";
6
+ import { fitHeight, isMeasurable, regionRows, stackedHeight, withBoundedWidth, withCellWidth, } from "../core/protocol.js";
7
7
  import { Measurement } from "../core/measure.js";
8
8
  import { cellCount } from "../core/cells.js";
9
9
  /**
@@ -16,23 +16,18 @@ import { cellCount } from "../core/cells.js";
16
16
  function growthRatio(ratio) {
17
17
  return Number.isFinite(ratio) && ratio > 0 ? ratio : 0;
18
18
  }
19
- // [LAW:single-enforcer] A leaf's content is a pane, not a printed unit — the
20
- // same reason panel.ts and table.ts clear `end` on a `RichText` they embed.
21
- // The string branch already built one with `end: ""`; a `RichText` passed
22
- // directly needs the same treatment, or it keeps its default `end: "\n"`
23
- // (rich-text-5ai code review) and draws a trailing blank row through
24
- // `Segment.cropLines`, which — unlike table.ts's `splitLines`-based cell
25
- // rendering — does not normalize a trailing empty line away.
26
- function toRenderable(renderable) {
27
- if (typeof renderable === "string") {
28
- return new RichText(renderable, { end: "" });
29
- }
30
- if (renderable instanceof RichText) {
31
- const copy = renderable.copy();
32
- copy.end = "";
33
- return copy;
34
- }
35
- return renderable;
19
+ /**
20
+ * A pane rendered into a region of `rows` and held to exactly that many lines,
21
+ * or — with `rows` undefined — rendered under the layout's own budget, as a
22
+ * ceiling, at its natural height.
23
+ *
24
+ * [LAW:single-enforcer] The region's setter shapes it (`fitHeight`). Forwarded
25
+ * unshaped, a pane whose content ran short pulled every pane below it up, and
26
+ * one that ran long pushed them down.
27
+ */
28
+ function paneLines(pane, options, rows) {
29
+ const height = rows === undefined ? stackedHeight(options.height) : { rows, exact: true };
30
+ return fitHeight(Segment.splitLines(pane.render({ ...options, height })), height);
36
31
  }
37
32
  export class Layout {
38
33
  name;
@@ -45,7 +40,7 @@ export class Layout {
45
40
  _splitDirection;
46
41
  constructor(renderable, options) {
47
42
  if (renderable !== undefined) {
48
- this._renderable = toRenderable(renderable);
43
+ this._renderable = embed(renderable);
49
44
  }
50
45
  this.name = options?.name;
51
46
  this.ratio = options?.ratio ?? 1;
@@ -110,7 +105,7 @@ export class Layout {
110
105
  this._splitDirection = "row";
111
106
  }
112
107
  update(renderable) {
113
- this._renderable = toRenderable(renderable);
108
+ this._renderable = embed(renderable);
114
109
  }
115
110
  getByName(name) {
116
111
  if (this.name === name)
@@ -151,29 +146,31 @@ export class Layout {
151
146
  }
152
147
  }
153
148
  *_renderColumn(children, options) {
154
- // Vertical stacking: each child gets full width, proportional height
155
- const totalHeight = options.maxHeight ?? options.height ?? 24;
156
- const heights = this._distributeSpace(children, totalHeight);
149
+ // Each pane gets full width and its share of the region. With no region —
150
+ // a ceiling, or no budget at all — a pane's share is its declared `size`,
151
+ // and a pane without one takes its content's height.
152
+ const region = regionRows(options.height);
153
+ const shares = region === undefined
154
+ ? children.map((child) => child.size)
155
+ : this._distributeSpace(children, region);
157
156
  for (let i = 0; i < children.length; i++) {
158
- const child = children[i];
159
- const childOptions = {
160
- ...options,
161
- maxHeight: heights[i],
162
- height: heights[i],
163
- };
164
- yield* child.render(childOptions);
157
+ for (const line of paneLines(children[i], options, shares[i])) {
158
+ yield* line;
159
+ yield Segment.line();
160
+ }
165
161
  }
166
162
  }
167
163
  *_renderRow(children, options) {
168
164
  // Horizontal side-by-side: divide the requested width once, then merge
169
165
  // child lines. The budget arrives parsed from `render` — unparsed, a NaN
170
166
  // width made every share NaN and the merge threw `Invalid array length`.
167
+ // Every pane stands in the whole region, and with none the merge pads the
168
+ // row to its tallest pane.
171
169
  const widths = this._distributeSpace(children, options.maxWidth);
170
+ const region = regionRows(options.height);
172
171
  const cells = children.map((child, i) => ({
173
172
  width: widths[i],
174
- lines: Segment.splitLines([
175
- ...child.render({ ...options, maxWidth: widths[i] }),
176
- ]),
173
+ lines: paneLines(child, { ...options, maxWidth: widths[i] }, region),
177
174
  }));
178
175
  yield* Segment.mergeHorizontal(cells);
179
176
  }
@@ -6,10 +6,11 @@
6
6
  * region. Good for spinners/progress bars below other output.
7
7
  * - **Alt-screen** (`altScreen: true`): enters the alternate screen buffer
8
8
  * on start, cursor-homes on each refresh (no clear flicker), and restores
9
- * the original buffer on stop. Good for full-screen TUI apps.
9
+ * the original buffer on stop. The screen is the frame's region, so a
10
+ * `Layout` fills it. Good for full-screen TUI apps.
10
11
  */
11
12
  import { Console } from "../core/console.js";
12
- import type { Renderable } from "../core/protocol.js";
13
+ import { type Renderable } from "../core/protocol.js";
13
14
  export interface LiveOptions {
14
15
  refreshPerSecond?: number;
15
16
  autoRefresh?: boolean;
@@ -42,6 +43,8 @@ export declare class Live {
42
43
  refresh?: boolean;
43
44
  }): void;
44
45
  refresh(): void;
45
- private _clearLast;
46
+ private _overflow;
47
+ private _erase;
48
+ private _release;
46
49
  private _writeCursorControl;
47
50
  }
@@ -6,11 +6,13 @@
6
6
  * region. Good for spinners/progress bars below other output.
7
7
  * - **Alt-screen** (`altScreen: true`): enters the alternate screen buffer
8
8
  * on start, cursor-homes on each refresh (no clear flicker), and restores
9
- * the original buffer on stop. Good for full-screen TUI apps.
9
+ * the original buffer on stop. The screen is the frame's region, so a
10
+ * `Layout` fills it. Good for full-screen TUI apps.
10
11
  */
11
12
  import { Console } from "../core/console.js";
12
13
  import { Segment } from "../core/segment.js";
13
14
  import { segmentsToString } from "../core/render.js";
15
+ import { fitHeight } from "../core/protocol.js";
14
16
  export class Live {
15
17
  _renderable;
16
18
  _console;
@@ -53,26 +55,37 @@ export class Live {
53
55
  this._writeCursorControl(false);
54
56
  if (this._autoRefresh) {
55
57
  const interval = Math.floor(1000 / this._refreshPerSecond);
56
- this._timer = setInterval(() => this.refresh(), interval);
58
+ // No caller is on the stack to run `stop()` for a frame that throws
59
+ // here, and in Node the throw ends the process — so the terminal is
60
+ // handed back first, and the error then goes on to wherever an uncaught
61
+ // one goes.
62
+ this._timer = setInterval(() => {
63
+ try {
64
+ this.refresh();
65
+ }
66
+ catch (error) {
67
+ this._release();
68
+ throw error;
69
+ }
70
+ }, interval);
57
71
  }
58
72
  }
59
73
  stop() {
60
74
  if (!this._started)
61
75
  return;
62
- this._started = false;
63
- if (this._timer) {
64
- clearInterval(this._timer);
65
- this._timer = undefined;
66
- }
67
- if (!this._transient) {
68
- this.refresh();
76
+ // [LAW:no-silent-failure] A final frame that throws still propagates, and
77
+ // the terminal is handed back on the way out.
78
+ try {
79
+ if (this._transient) {
80
+ this._console.file.write(this._erase());
81
+ this._lastLineCount = 0;
82
+ }
83
+ else {
84
+ this.refresh();
85
+ }
69
86
  }
70
- else {
71
- this._clearLast();
72
- }
73
- this._writeCursorControl(true);
74
- if (this._altScreen) {
75
- this._console.file.write("\x1b[0m\x1b[?1049l"); // reset attrs + exit alt screen
87
+ finally {
88
+ this._release();
76
89
  }
77
90
  }
78
91
  update(renderable, options) {
@@ -84,45 +97,76 @@ export class Live {
84
97
  }
85
98
  }
86
99
  refresh() {
87
- if (!this._renderable)
100
+ // A frame is drawn only while this Live holds the terminal, as in Rich:
101
+ // before `start()` or after the hand-back it would land on a screen that
102
+ // belongs to someone else — the main buffer, under the user's own output.
103
+ if (!this._started || !this._renderable)
88
104
  return;
89
- if (this._altScreen) {
90
- // Alt-screen mode: cursor home (first refresh clears the buffer)
91
- this._console.file.write(this._firstRefresh ? "\x1b[2J\x1b[H" : "\x1b[H");
92
- this._firstRefresh = false;
93
- }
94
- else {
95
- // Inline mode: erase previous output
96
- this._clearLast();
97
- }
98
- const segments = [...this._renderable.render(this._console.options)];
99
- const lines = Segment.splitLines(segments);
100
- const maxHeight = this._console.height;
101
- let displayLines = lines;
102
- if (this._verticalOverflow !== "visible" && lines.length > maxHeight) {
103
- displayLines = lines.slice(0, maxHeight);
104
- if (this._verticalOverflow === "ellipsis" && displayLines.length > 0) {
105
- displayLines[displayLines.length - 1] = [new Segment("...")];
106
- }
107
- }
105
+ // [LAW:dataflow-not-control-flow] Both modes render under the terminal's
106
+ // rows and differ only in what those rows are: the alternate screen is a
107
+ // region the frame stands in, and an inline frame keeps its natural height
108
+ // under them as a ceiling. Live set the budget, so Live shapes what comes
109
+ // back — its overflow policy, then `fitHeight`, which pads only a region.
110
+ const options = this._console.options;
111
+ const height = { rows: options.height.rows, exact: this._altScreen };
112
+ const lines = Segment.splitLines(this._renderable.render({ ...options, height }));
113
+ const displayLines = fitHeight(this._overflow(lines, height.rows), height);
108
114
  // [LAW:single-enforcer] Per-line encoding routes through the same
109
115
  // tree-coalescer `Console._writeSegments` uses, so Live frames coalesce
110
116
  // adjacent same-style segments into shared SGR pairs on the wire and
111
117
  // encode for the console's own destination.
118
+ // The alternate screen is not erased between frames — the cursor only goes
119
+ // home — so each of its rows is erased before it is drawn, or a shorter
120
+ // frame leaves the last one's rows and line tails showing. The erase comes
121
+ // first because the cursor is then at the row's start; after a row that
122
+ // fills the width it would take the last cell. An inline frame's rows are
123
+ // erased by `_erase`.
124
+ const lead = this._altScreen ? "\x1b[2K" : "";
112
125
  const destination = this._console.destination;
113
126
  const output = displayLines
114
- .map((line) => segmentsToString(line, destination))
127
+ .map((line) => lead + segmentsToString(line, destination))
115
128
  .join("\n");
116
- this._console.file.write(output + "\n");
117
- this._lastLineCount = displayLines.length;
129
+ // [LAW:effects-at-boundaries] The frame is fully rendered before a byte
130
+ // reaches the terminal, and whatever takes the last frame away goes out in
131
+ // the same write as the new one — so a render that throws leaves the last
132
+ // good frame showing. The first alternate-screen frame clears the buffer
133
+ // it walked into; after that the cursor only goes home.
134
+ const replace = this._altScreen ? (this._firstRefresh ? "\x1b[2J\x1b[H" : "\x1b[H") : this._erase();
135
+ // Inline, the newline leaves the cursor under the frame, where `_erase`
136
+ // counts up from. On the alternate screen the next frame starts from home,
137
+ // and a newline after a full-height frame's last row scrolls its first row
138
+ // off the top.
139
+ this._console.file.write(replace + (this._altScreen ? output : output + "\n"));
140
+ this._firstRefresh = false;
141
+ // What `_erase` erases: an inline frame's rows. An alternate-screen frame
142
+ // is erased by leaving the buffer.
143
+ this._lastLineCount = this._altScreen ? 0 : displayLines.length;
118
144
  }
119
- _clearLast() {
120
- if (this._lastLineCount > 0) {
121
- const stream = this._console.file;
122
- for (let i = 0; i < this._lastLineCount; i++) {
123
- stream.write("\x1b[1A\x1b[2K");
124
- }
125
- this._lastLineCount = 0;
145
+ // Lines past `rows`: dropped, with the last kept row replaced by an ellipsis,
146
+ // or left for the terminal to scroll.
147
+ _overflow(lines, rows) {
148
+ if (this._verticalOverflow === "visible" || lines.length <= rows)
149
+ return lines;
150
+ const kept = lines.slice(0, rows);
151
+ if (this._verticalOverflow === "ellipsis" && kept.length > 0) {
152
+ kept[kept.length - 1] = [new Segment("...")];
153
+ }
154
+ return kept;
155
+ }
156
+ // Cursor-up and erase-line for each row of the last inline frame.
157
+ _erase() {
158
+ return "\x1b[1A\x1b[2K".repeat(this._lastLineCount);
159
+ }
160
+ // [LAW:single-enforcer] The one way the terminal is handed back — the timer
161
+ // stopped, the cursor shown, the alternate screen left — whether `stop()`
162
+ // ran or a frame threw with no caller to run it.
163
+ _release() {
164
+ this._started = false;
165
+ clearInterval(this._timer);
166
+ this._timer = undefined;
167
+ this._writeCursorControl(true);
168
+ if (this._altScreen) {
169
+ this._console.file.write("\x1b[0m\x1b[?1049l"); // reset attrs + exit alt screen
126
170
  }
127
171
  }
128
172
  _writeCursorControl(show) {
@@ -16,7 +16,7 @@ export declare class Markdown implements Renderable, Measurable {
16
16
  readonly inlineCodeStyle: string | Style;
17
17
  readonly hyperlinks: boolean;
18
18
  constructor(markdown: string, options?: MarkdownOptions);
19
- render(options: RenderOptions): Iterable<Segment>;
19
+ render(rawOptions: RenderOptions): Iterable<Segment>;
20
20
  measure(options: RenderOptions): {
21
21
  minimum: number;
22
22
  maximum: number;
@@ -6,7 +6,7 @@ import { Segment } from "../core/segment.js";
6
6
  import { Style } from "../core/style.js";
7
7
  import { RichText } from "../core/text.js";
8
8
  import { Rule } from "./rule.js";
9
- import { getStyle } from "../core/protocol.js";
9
+ import { drawable, getStyle, stackedHeight } from "../core/protocol.js";
10
10
  function tokenize(markdown) {
11
11
  const lines = markdown.split("\n");
12
12
  const tokens = [];
@@ -124,7 +124,9 @@ export class Markdown {
124
124
  this.inlineCodeStyle = options?.inlineCodeStyle ?? "markdown.code";
125
125
  this.hyperlinks = options?.hyperlinks !== false;
126
126
  }
127
- *render(options) {
127
+ *render(rawOptions) {
128
+ // Every block below is one of a stack.
129
+ const options = { ...rawOptions, height: stackedHeight(rawOptions.height) };
128
130
  const tokens = tokenize(this.markdown);
129
131
  for (const token of tokens) {
130
132
  switch (token.type) {
@@ -156,7 +158,7 @@ export class Markdown {
156
158
  break;
157
159
  }
158
160
  case "list_item": {
159
- const bullet = token.ordered ? `${token.index}. ` : " • ";
161
+ const bullet = token.ordered ? `${token.index}. ` : drawable(options, " • ", " * ");
160
162
  yield new Segment(bullet);
161
163
  const text = applyInlineStyles(token.text);
162
164
  yield* text.render({ ...options, maxWidth: options.maxWidth - bullet.length });
@@ -164,7 +166,7 @@ export class Markdown {
164
166
  }
165
167
  case "blockquote": {
166
168
  const quoteStyle = Style.parse("dim italic");
167
- yield new Segment("▎ ", getStyle(options, "markdown.hr"));
169
+ yield new Segment(drawable(options, "▎ ", "| "), getStyle(options, "markdown.hr"));
168
170
  const text = applyInlineStyles(token.text);
169
171
  yield* Segment.applyStyle([...text.render(options)], quoteStyle);
170
172
  yield Segment.line();
@@ -5,7 +5,7 @@ import { Segment } from "../core/segment.js";
5
5
  import { cellCount } from "../core/cells.js";
6
6
  import { NULL_STYLE } from "../core/style.js";
7
7
  import { Measurement } from "../core/measure.js";
8
- import { getStyle, isMeasurable, withBoundedWidth, withCellWidth } from "../core/protocol.js";
8
+ import { fitHeight, getStyle, insetHeight, isMeasurable, withBoundedWidth, withCellWidth } from "../core/protocol.js";
9
9
  /**
10
10
  * Parses the shapes a caller may write padding in — one number, a vertical/
11
11
  * horizontal pair, or all four sides — into the four-sided form every
@@ -91,9 +91,10 @@ export class Padding {
91
91
  const innerOptions = {
92
92
  ...options,
93
93
  maxWidth: geometry.contentWidth,
94
+ height: insetHeight(options.height, this.top + this.bottom),
94
95
  };
95
96
  const segments = [...this.renderable.render(innerOptions)];
96
- const lines = Segment.splitLines(segments);
97
+ const lines = fitHeight(Segment.splitLines(segments), innerOptions.height);
97
98
  const resolved = getStyle(options, this.style);
98
99
  const style = resolved.isNull ? undefined : resolved;
99
100
  // Zero-length spans need no branch to suppress: the wire boundary drops