@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
package/dist/core/text.js CHANGED
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * RichText — styled text with spans. The primary text type for the library.
3
3
  */
4
- import { cellLen, cellCount } from "./cells.js";
4
+ import { cellLen, cellCount, cellFit, cellFitEnd, asCellCol } from "./cells.js";
5
5
  import { divideLine } from "./wrap.js";
6
6
  import { Segment } from "./segment.js";
7
7
  import { Style, NULL_STYLE, StyleSyntaxError } from "./style.js";
8
8
  import { stripOscTerminators } from "./osc8.js";
9
- import { getStyle, withBoundedWidth } from "./protocol.js";
9
+ import { drawable, getStyle, withBoundedWidth } from "./protocol.js";
10
10
  // Strip control characters except \t and \n
11
11
  // [LAW:single-enforcer] Single place where control chars are sanitized
12
12
  const CONTROL_CHARS_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
13
- function stripControlChars(text) {
13
+ export function stripControlChars(text) {
14
14
  return text.replace(CONTROL_CHARS_RE, "");
15
15
  }
16
16
  const TRAILING_WHITESPACE_RE = /\s+$/;
@@ -66,9 +66,16 @@ function sanitizeStyleLink(style) {
66
66
  * RichText knows when the name arrives — the reference stores span styles the
67
67
  * same way. A string holds no link until it is parsed, so only a `Style` has
68
68
  * one to sanitize here.
69
+ *
70
+ * [LAW:single-enforcer] Nor does a RichText keep an anchor (`./anchor.ts`). It
71
+ * lays its text out anew, so a stamp naming where a cell sat in the output it
72
+ * came from would name the wrong cell once it wraps, slices or overlaps; the
73
+ * owner that renders the RichText is the one that stamps its cells.
69
74
  */
70
75
  function admitStyle(style) {
71
- return style instanceof Style ? sanitizeStyleLink(style) : style;
76
+ if (!(style instanceof Style))
77
+ return style;
78
+ return sanitizeStyleLink(style.anchor ? style.withAnchor(undefined) : style);
72
79
  }
73
80
  /** A style that adds nothing: the empty definition, or a null `Style`. */
74
81
  function isEmptyStyle(style) {
@@ -264,6 +271,9 @@ export class RichText {
264
271
  }
265
272
  const offset = this._text.length;
266
273
  this._text += content._text;
274
+ // Its base style becomes a span under its own spans, as the reference's
275
+ // `append` does, so appended text looks as it did alone.
276
+ this._addSpan(offset, this._text.length, content._style);
267
277
  for (const span of content._spans) {
268
278
  this._spans.push(span.move(offset));
269
279
  }
@@ -314,9 +324,10 @@ export class RichText {
314
324
  * is admitted as given.
315
325
  */
316
326
  _addSpan(start, end, style) {
317
- if (isEmptyStyle(style))
327
+ const admitted = admitStyle(style);
328
+ if (isEmptyStyle(admitted))
318
329
  return;
319
- this._spans.push(new Span(start, end, admitStyle(style)));
330
+ this._spans.push(new Span(start, end, admitted));
320
331
  }
321
332
  stylize(style, start, end) {
322
333
  const len = this._text.length;
@@ -486,7 +497,10 @@ export class RichText {
486
497
  * in the middle.
487
498
  *
488
499
  * Marker default is `"\u2026"`. Pass `marker: ""` for raw cropping without an
489
- * indicator glyph.
500
+ * indicator glyph. A marker wider than `width` is itself cut to the glyphs
501
+ * that fit, and the text takes whatever cells that leaves — all of them, when
502
+ * not one glyph of the marker fits — so the result is never wider than asked.
503
+ * Every cut, marker and text alike, falls between grapheme clusters.
490
504
  *
491
505
  * Spans are preserved through the cut: characters that survive keep their
492
506
  * styling; the marker (if any) is inserted as plain text with no span.
@@ -500,13 +514,12 @@ export class RichText {
500
514
  if (this.cellLength <= width)
501
515
  return this;
502
516
  const mode = options?.mode ?? "right";
503
- const marker = options?.marker ?? "\u2026";
504
- const markerWidth = cellLen(marker);
505
- if (width <= 0) {
506
- this.plain = "";
507
- return this;
508
- }
509
- const budget = Math.max(0, width - markerWidth);
517
+ // [LAW:dataflow-not-control-flow] the marker is fitted to the width as a
518
+ // value, so width 0 and a marker wider than the width run the same walk
519
+ // as every other call: what is drawn plus what is kept is `cells` wide.
520
+ const cells = cellCount(width);
521
+ const marker = cellFit(options?.marker ?? "\u2026", cells);
522
+ const budget = asCellCol(cells - cellLen(marker));
510
523
  if (mode === "right") {
511
524
  this._cropRightTo(budget);
512
525
  if (marker)
@@ -522,11 +535,11 @@ export class RichText {
522
535
  return this;
523
536
  }
524
537
  // middle
525
- const leftBudget = Math.floor(budget / 2);
526
- const rightBudget = budget - leftBudget;
538
+ const leftBudget = asCellCol(Math.floor(budget / 2));
539
+ const rightBudget = asCellCol(budget - leftBudget);
527
540
  // Find the char-index ranges to keep from each side.
528
- const leftEndCharIdx = this._cellPrefixCharLength(leftBudget);
529
- const rightStartCharIdx = this._cellSuffixStartCharIndex(rightBudget);
541
+ const leftEndCharIdx = cellFit(this._text, leftBudget).length;
542
+ const rightStartCharIdx = this._text.length - cellFitEnd(this._text, rightBudget).length;
530
543
  const leftText = this._text.slice(0, leftEndCharIdx);
531
544
  const rightText = this._text.slice(rightStartCharIdx);
532
545
  const droppedStart = leftEndCharIdx;
@@ -557,11 +570,10 @@ export class RichText {
557
570
  return this;
558
571
  }
559
572
  _cropRightTo(targetWidth) {
560
- const charIdx = this._cellPrefixCharLength(targetWidth);
561
- this.plain = this._text.slice(0, charIdx);
573
+ this.plain = cellFit(this._text, targetWidth);
562
574
  }
563
575
  _cropLeftTo(targetWidth) {
564
- const charIdx = this._cellSuffixStartCharIndex(targetWidth);
576
+ const charIdx = this._text.length - cellFitEnd(this._text, targetWidth).length;
565
577
  // Shift spans left by charIdx; clip spans that started before.
566
578
  const shift = -charIdx;
567
579
  this._spans = this._spans
@@ -575,34 +587,6 @@ export class RichText {
575
587
  .filter((s) => s !== undefined);
576
588
  this._text = this._text.slice(charIdx);
577
589
  }
578
- /** Number of char-index code units that fit within `targetWidth` cell columns from the left. */
579
- _cellPrefixCharLength(targetWidth) {
580
- let width = 0;
581
- let charIndex = 0;
582
- for (const char of this._text) {
583
- const charWidth = cellLen(char);
584
- if (width + charWidth > targetWidth)
585
- break;
586
- width += charWidth;
587
- charIndex += char.length;
588
- }
589
- return charIndex;
590
- }
591
- /** Char-index at which the suffix of `targetWidth` cell columns starts. */
592
- _cellSuffixStartCharIndex(targetWidth) {
593
- // Walk from right: accumulate widths of trailing chars until we hit the budget.
594
- const chars = [...this._text];
595
- let width = 0;
596
- let kept = 0;
597
- for (let i = chars.length - 1; i >= 0; i--) {
598
- const w = cellLen(chars[i]);
599
- if (width + w > targetWidth)
600
- break;
601
- width += w;
602
- kept += chars[i].length;
603
- }
604
- return this._text.length - kept;
605
- }
606
590
  // --- Alignment ---
607
591
  align(justify, width) {
608
592
  const currentWidth = this.cellLength;
@@ -671,9 +655,8 @@ export class RichText {
671
655
  * `{{ red "x" }}{{ blue "y" }}` evaluates to two fragments — one with
672
656
  * wrapping `style = red`, one with `blue` — and consumers that want a
673
657
  * single styled string for downstream rendering need both styles
674
- * preserved as spans on the concatenated result. The plain `append()`
675
- * propagates spans only, so this static does the additional work of
676
- * lifting `frag.style` into a span before appending.
658
+ * preserved as spans on the concatenated result, which is what `append()`
659
+ * does with each fragment.
677
660
  *
678
661
  * Empty input → empty `RichText` with `end: ""`. Caller can override
679
662
  * `end` (defaults to `""` — the engine-output case rarely wants a
@@ -681,14 +664,8 @@ export class RichText {
681
664
  */
682
665
  static fromFragments(fragments, options) {
683
666
  const result = new RichText("", { end: options?.end ?? "" });
684
- for (const frag of fragments) {
685
- const start = result.length;
686
- result.append(frag.plain);
687
- result.stylize(frag.style, start, result.length);
688
- for (const span of frag.spans) {
689
- result.stylize(span.style, start + span.start, start + span.end);
690
- }
691
- }
667
+ for (const frag of fragments)
668
+ result.append(frag);
692
669
  return result;
693
670
  }
694
671
  // --- Renderable ---
@@ -704,21 +681,28 @@ export class RichText {
704
681
  // which pads — and `" ".repeat(Infinity)` throws.
705
682
  const maxWidth = cellCount(withBoundedWidth(options, this).maxWidth);
706
683
  const overflow = this._overflow ?? options.overflow ?? "fold";
707
- const justify = this._justify ?? options.justify;
708
684
  const noWrap = this._noWrap || (options.noWrap ?? false);
709
- // The width a line is cut to, which is not always the width it is
710
- // justified in. `noWrap` means the line is not bounded at all: it leaves at
711
- // its natural width and whatever asked for it decides about the overhang —
712
- // `Console`'s soft wrap and `FlexStrip`'s too-wide fallback both want the
713
- // text intact rather than cropped.
685
+ // Two widths, and they are the reference's `no_wrap` and `"ignore"` told
686
+ // apart. `edge` is where a line is cut: gone under `"ignore"`, so the line
687
+ // leaves at its natural width and whatever drew it decides about the
688
+ // overhang. `wrapAt` is where a line may break: gone under `noWrap` too,
689
+ // which keeps one line per line and still cuts it at the edge by the
690
+ // overflow method — Rich's `Text.wrap` truncates its no-wrap arm.
714
691
  //
715
- // [LAW:dataflow-not-control-flow] It reaches the pipeline as a width, not
716
- // as a step to skip: an unbounded budget has no edge to break at, so
692
+ // [LAW:dataflow-not-control-flow] Each reaches the pipeline as a width, not
693
+ // as a step to skip: an unbounded width has no edge to break at, so
717
694
  // `divideLine` finds no cuts and `_fitLine` finds nothing past the edge,
718
695
  // and every line runs the same three steps. `Infinity` is already this
719
696
  // library's spelling of an unbounded width offer — `withBoundedWidth` in
720
697
  // protocol.ts parses one on the way in.
721
- const budget = noWrap ? cellCount(Infinity) : maxWidth;
698
+ const edge = overflow === "ignore" ? cellCount(Infinity) : maxWidth;
699
+ const wrapAt = noWrap ? cellCount(Infinity) : edge;
700
+ // A line with no edge has no width to be justified in, so under `"ignore"`
701
+ // it is not justified — the reference's `Text.wrap` returns that arm before
702
+ // `justify`. `undefined` is the placement that leaves a line as it is.
703
+ const justify = overflow === "ignore" ? undefined : this._justify ?? options.justify;
704
+ // One cell either way, so the marker's stand-in leaves the cut where it was.
705
+ const ellipsis = drawable(options, "\u2026", ".");
722
706
  const endsWithNewline = text.endsWith("\n");
723
707
  for (let index = 0; index < logicalLines.length; index += 1) {
724
708
  const line = logicalLines[index];
@@ -726,9 +710,9 @@ export class RichText {
726
710
  // Wrap first, overflow last — the reference's order, and the reason a
727
711
  // long sentence grows a table row while an unbreakable word in the same
728
712
  // column still ellipsizes.
729
- const cuts = divideLine(plainOf(line), budget, { fold: overflow === "fold" });
713
+ const cuts = divideLine(plainOf(line), wrapAt, { fold: overflow === "fold" });
730
714
  const wrapped = Segment.divide(line, cuts);
731
- const placed = this._justifyLines(wrapped.map((piece) => [...this._fitLine(piece, budget, overflow)]), maxWidth, base, justify);
715
+ const placed = this._justifyLines(wrapped.map((piece) => [...this._fitLine(piece, edge, overflow, ellipsis)]), maxWidth, base, justify);
732
716
  for (let piece = 0; piece < placed.length; piece += 1) {
733
717
  yield* placed[piece];
734
718
  if (piece < placed.length - 1 || terminateLine) {
@@ -954,11 +938,13 @@ export class RichText {
954
938
  *
955
939
  * Everything reaching here already survived wrapping, so the only text still
956
940
  * too wide is text no break could help: a word longer than the canvas under
957
- * a non-folding overflow method, a glyph wider than the budget, or a canvas
958
- * with no cells at all. That is what makes the overflow method a last
959
- * resort rather than the first thing a long cell meets.
941
+ * a non-folding overflow method, a glyph wider than the budget, a canvas
942
+ * with no cells at all, or a `noWrap` line, which no break was allowed to
943
+ * help. That is what makes the overflow method a last resort rather than the
944
+ * first thing a long cell meets. `"fold"` has nothing left to fold here and
945
+ * cuts as `"crop"` does, as the reference's `truncate` does.
960
946
  */
961
- *_fitLine(line, maxWidth, overflow) {
947
+ *_fitLine(line, maxWidth, overflow, ellipsis) {
962
948
  const lineWidth = Segment.getLineLength(line);
963
949
  const contentWidth = lineWidth - hangingWhitespace(line);
964
950
  // Whitespace hanging past the edge is the wrap's own padding: cropping it
@@ -982,7 +968,7 @@ export class RichText {
982
968
  // three different kinds of nothing.
983
969
  if (overflow === "ellipsis" && maxWidth > 0) {
984
970
  yield* Segment.adjustLineLength(line, maxWidth - 1, undefined, false);
985
- yield new Segment("\u2026");
971
+ yield new Segment(ellipsis);
986
972
  return;
987
973
  }
988
974
  yield* Segment.adjustLineLength(line, maxWidth, undefined, false);
@@ -0,0 +1,121 @@
1
+ /**
2
+ * App — the runtime a terminal application runs in: it takes the terminal,
3
+ * paints one renderable on it every frame, and hands the terminal back on
4
+ * every path out.
5
+ *
6
+ * [LAW:single-enforcer] Every change this runtime makes to the terminal —
7
+ * the alternate screen, pointer reporting, the hidden cursor, raw mode — is
8
+ * made by `enter` and
9
+ * undone by `leave`, and nothing else touches them. Stopping, suspending, an
10
+ * error in a frame and the program ending by a signal or a crash all hand the
11
+ * terminal back through `leave`, so there is one list of what to undo and no
12
+ * exit path can hold a shorter one.
13
+ *
14
+ * [LAW:one-source-of-truth] The frame on screen is one value: the rows last
15
+ * painted, carrying on every cell where its owner drew it (`core/anchor`).
16
+ * Nothing about it is cached beside it, so a resize invalidates nothing — it
17
+ * is input to the next frame, which replaces the rows and their anchors at
18
+ * once. An event arriving before that repaint lands on the frame on screen,
19
+ * which is the one the user pointed at.
20
+ *
21
+ * [LAW:locality-or-seam] The terminal's bytes are identical under every host
22
+ * and live here: entering and leaving the alternate screen, the cursor, the
23
+ * frame's encoding. What differs per host — whether the program can end
24
+ * under us, how it is suspended — is the `TerminalHost`'s, so the same app
25
+ * runs in node and in a browser with no path of its own for either.
26
+ */
27
+ import { Segment } from "../core/segment.js";
28
+ import { type Renderable } from "../core/protocol.js";
29
+ import type { Unsubscribe } from "../core/subscription.js";
30
+ import type { TerminalHost } from "./terminal-host.js";
31
+ /**
32
+ * Where the frame is painted. `alternate` is the whole terminal, in the
33
+ * alternate screen buffer, so the rows the program printed before it are
34
+ * there again when it stops; the terminal reports the pointer on it. `inline`
35
+ * starts at the cursor's line, as tall as the frame, and stays on the
36
+ * terminal when the app stops. It gets no pointer events: the terminal
37
+ * reports a pointer by its row on the screen, and the app does not know
38
+ * which row of the screen its frame starts on.
39
+ */
40
+ export type Surface = "alternate" | "inline";
41
+ /**
42
+ * Where an app is in its life. `idle` has not run; `running` holds the
43
+ * terminal and paints; `suspended` has handed it back until the host resumes
44
+ * the program; `stopped` has handed it back for good.
45
+ */
46
+ export type AppPhase = "idle" | "running" | "suspended" | "stopped";
47
+ export interface AppOptions {
48
+ /** The terminal the app runs on. The app starts and stops it. */
49
+ readonly host: TerminalHost;
50
+ readonly surface: Surface;
51
+ /**
52
+ * The app's root renderable, asked for once per frame, so a view of the
53
+ * app's state is drawn from the state as it is at that frame.
54
+ */
55
+ readonly view: () => Renderable;
56
+ /**
57
+ * The terminal can draw only ASCII, so every frame draws its boxes, guides
58
+ * and widget marks with ASCII characters. Default false.
59
+ */
60
+ readonly asciiOnly?: boolean;
61
+ }
62
+ export declare class App {
63
+ private readonly host;
64
+ private readonly surface;
65
+ private readonly view;
66
+ private readonly console;
67
+ private _phase;
68
+ private _frame;
69
+ private rows;
70
+ private refreshQueued;
71
+ private subscriptions;
72
+ private readonly paintHandlers;
73
+ private settle;
74
+ constructor(options: AppOptions);
75
+ get phase(): AppPhase;
76
+ /** The rows on screen now, as painted: what a pointer event lands on. */
77
+ get frame(): readonly (readonly Segment[])[];
78
+ /**
79
+ * Take the terminal, paint the first frame, and resolve when the app stops
80
+ * — or reject with the error a frame threw or `fail` was given, the terminal
81
+ * handed back first.
82
+ * When the program ends under the app, `run` does not settle: nothing after
83
+ * it runs, as nothing after it would have without the app. An app runs once.
84
+ */
85
+ run(): Promise<void>;
86
+ /**
87
+ * Paint a new frame from the view, once, in a later task: every change made
88
+ * before then lands in the one frame.
89
+ *
90
+ * [LAW:no-ambient-temporal-coupling] A task, not a microtask. Input is read
91
+ * between tasks, so a view whose every frame asks for another still hears
92
+ * its keys — Ctrl+C included — where a microtask chain would starve them
93
+ * for as long as it ran.
94
+ */
95
+ refresh(): void;
96
+ /**
97
+ * Hand the terminal to whatever launched the program, and take it back and
98
+ * repaint when that resumes it. What sends the app here is the app's own —
99
+ * in raw mode Ctrl+Z arrives as a key.
100
+ */
101
+ suspend(): Promise<void>;
102
+ /**
103
+ * Hear each frame once it is on screen — what `frame` now returns. A
104
+ * handler that throws fails the app as a frame that throws does.
105
+ */
106
+ onPaint(handler: (frame: readonly (readonly Segment[])[]) => void): Unsubscribe;
107
+ /** Hand the terminal back for good; `run` resolves. */
108
+ stop(): void;
109
+ /**
110
+ * Hand the terminal back for good; `run` rejects with `error`. For an error
111
+ * thrown where the app's own code runs outside a frame — a key handler, a
112
+ * timer — which would otherwise reach neither `run`'s caller nor the
113
+ * terminal's hand-back.
114
+ */
115
+ fail(error: unknown): void;
116
+ private enter;
117
+ private leave;
118
+ private end;
119
+ private paint;
120
+ private draw;
121
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * App — the runtime a terminal application runs in: it takes the terminal,
3
+ * paints one renderable on it every frame, and hands the terminal back on
4
+ * every path out.
5
+ *
6
+ * [LAW:single-enforcer] Every change this runtime makes to the terminal —
7
+ * the alternate screen, pointer reporting, the hidden cursor, raw mode — is
8
+ * made by `enter` and
9
+ * undone by `leave`, and nothing else touches them. Stopping, suspending, an
10
+ * error in a frame and the program ending by a signal or a crash all hand the
11
+ * terminal back through `leave`, so there is one list of what to undo and no
12
+ * exit path can hold a shorter one.
13
+ *
14
+ * [LAW:one-source-of-truth] The frame on screen is one value: the rows last
15
+ * painted, carrying on every cell where its owner drew it (`core/anchor`).
16
+ * Nothing about it is cached beside it, so a resize invalidates nothing — it
17
+ * is input to the next frame, which replaces the rows and their anchors at
18
+ * once. An event arriving before that repaint lands on the frame on screen,
19
+ * which is the one the user pointed at.
20
+ *
21
+ * [LAW:locality-or-seam] The terminal's bytes are identical under every host
22
+ * and live here: entering and leaving the alternate screen, the cursor, the
23
+ * frame's encoding. What differs per host — whether the program can end
24
+ * under us, how it is suspended — is the `TerminalHost`'s, so the same app
25
+ * runs in node and in a browser with no path of its own for either.
26
+ */
27
+ import { Console } from "../core/console.js";
28
+ import { Segment } from "../core/segment.js";
29
+ import { segmentsToString } from "../core/render.js";
30
+ import { fitHeight } from "../core/protocol.js";
31
+ import { hostEnvironment } from "./host-environment.js";
32
+ // Button presses, motion and the wheel, in the SGR encoding: coordinates as
33
+ // decimal numbers, so a terminal wider than 223 columns still reports them.
34
+ const POINTER_ON = "\x1b[?1006h\x1b[?1000h\x1b[?1003h";
35
+ const POINTER_OFF = "\x1b[?1003l\x1b[?1000l\x1b[?1006l";
36
+ const SURFACES = {
37
+ alternate: {
38
+ exact: true,
39
+ enter: "\x1b[?1049h" + POINTER_ON,
40
+ leave: () => POINTER_OFF + "\x1b[?1049l",
41
+ home: () => "\x1b[H",
42
+ // The frame is every row of the screen, so there is nothing left under it
43
+ // — and after the terminal shrinks, painting the old count would scroll.
44
+ painted: (frameRows) => frameRows,
45
+ },
46
+ inline: {
47
+ exact: false,
48
+ enter: "",
49
+ // Below the frame, where the program's next line belongs.
50
+ leave: (rows) => (rows > 0 ? "\n" : ""),
51
+ // After N rows written with N-1 newlines between them, the cursor is on
52
+ // the last. `ESC[0A` still moves a row on some terminals, so a one-row
53
+ // frame is returned to with the carriage return alone.
54
+ home: (rows) => (rows > 1 ? `\x1b[${rows - 1}A\r` : "\r"),
55
+ // A shorter frame blanks the rows the last one left below it.
56
+ painted: (frameRows, lastRows) => Math.max(frameRows, lastRows),
57
+ },
58
+ };
59
+ const HIDE_CURSOR = "\x1b[?25l";
60
+ const SHOW_CURSOR = "\x1b[?25h";
61
+ const RESET_STYLE = "\x1b[0m";
62
+ const ERASE_LINE = "\x1b[2K";
63
+ export class App {
64
+ host;
65
+ surface;
66
+ view;
67
+ // [LAW:one-source-of-truth] The host is the console's whole environment:
68
+ // its size, its colours, where bytes go. Every frame renders with this
69
+ // console's options and is encoded for its destination.
70
+ console;
71
+ _phase = "idle";
72
+ _frame = [];
73
+ // The last frame's own rows on the terminal — what `home` rewinds.
74
+ rows = 0;
75
+ refreshQueued = false;
76
+ subscriptions = [];
77
+ paintHandlers = new Set();
78
+ settle = () => { };
79
+ constructor(options) {
80
+ this.host = options.host;
81
+ this.surface = SURFACES[options.surface];
82
+ this.view = options.view;
83
+ this.console = new Console({ environment: hostEnvironment(options.host), asciiOnly: options.asciiOnly });
84
+ }
85
+ get phase() {
86
+ return this._phase;
87
+ }
88
+ /** The rows on screen now, as painted: what a pointer event lands on. */
89
+ get frame() {
90
+ return this._frame;
91
+ }
92
+ /**
93
+ * Take the terminal, paint the first frame, and resolve when the app stops
94
+ * — or reject with the error a frame threw or `fail` was given, the terminal
95
+ * handed back first.
96
+ * When the program ends under the app, `run` does not settle: nothing after
97
+ * it runs, as nothing after it would have without the app. An app runs once.
98
+ */
99
+ run() {
100
+ if (this._phase !== "idle") {
101
+ return Promise.reject(new Error(`App.run: an app runs once, and this one is ${this._phase}`));
102
+ }
103
+ return new Promise((resolve, reject) => {
104
+ this.settle = (outcome) => {
105
+ switch (outcome.kind) {
106
+ case "stopped":
107
+ return resolve();
108
+ case "failed":
109
+ return reject(outcome.error);
110
+ // The rest of a crash — its report, then its exit — is still
111
+ // running, and code resumed after `run` would run inside it.
112
+ case "ended":
113
+ return;
114
+ }
115
+ };
116
+ this._phase = "running";
117
+ this.host.start();
118
+ this.subscriptions = [
119
+ this.host.onResize(() => this.refresh()),
120
+ // The program is ending under the app — by a signal, a crash, or an
121
+ // exit the app's own code never saw. Ending hands the terminal back,
122
+ // and the program then ends as it was going to.
123
+ this.host.onExit(() => this.end({ kind: "ended" })),
124
+ ];
125
+ this.enter();
126
+ this.paint();
127
+ });
128
+ }
129
+ /**
130
+ * Paint a new frame from the view, once, in a later task: every change made
131
+ * before then lands in the one frame.
132
+ *
133
+ * [LAW:no-ambient-temporal-coupling] A task, not a microtask. Input is read
134
+ * between tasks, so a view whose every frame asks for another still hears
135
+ * its keys — Ctrl+C included — where a microtask chain would starve them
136
+ * for as long as it ran.
137
+ */
138
+ refresh() {
139
+ if (this.refreshQueued)
140
+ return;
141
+ this.refreshQueued = true;
142
+ setTimeout(() => {
143
+ this.refreshQueued = false;
144
+ // [LAW:types-are-the-program] Only a running app holds the terminal; a
145
+ // suspended one repaints when it resumes, and a stopped one never does.
146
+ if (this._phase === "running")
147
+ this.paint();
148
+ });
149
+ }
150
+ /**
151
+ * Hand the terminal to whatever launched the program, and take it back and
152
+ * repaint when that resumes it. What sends the app here is the app's own —
153
+ * in raw mode Ctrl+Z arrives as a key.
154
+ */
155
+ async suspend() {
156
+ if (this._phase !== "running")
157
+ return;
158
+ this._phase = "suspended";
159
+ this.leave();
160
+ await this.host.suspend();
161
+ // Stopped while suspended — by a signal, say: the terminal is already
162
+ // handed back for good.
163
+ if (this._phase !== "suspended")
164
+ return;
165
+ this._phase = "running";
166
+ // What the last frame occupied is gone under whatever ran meanwhile, so
167
+ // the next one starts where the cursor is.
168
+ this.rows = 0;
169
+ this.enter();
170
+ this.paint();
171
+ }
172
+ /**
173
+ * Hear each frame once it is on screen — what `frame` now returns. A
174
+ * handler that throws fails the app as a frame that throws does.
175
+ */
176
+ onPaint(handler) {
177
+ this.paintHandlers.add(handler);
178
+ return () => this.paintHandlers.delete(handler);
179
+ }
180
+ /** Hand the terminal back for good; `run` resolves. */
181
+ stop() {
182
+ this.end({ kind: "stopped" });
183
+ }
184
+ /**
185
+ * Hand the terminal back for good; `run` rejects with `error`. For an error
186
+ * thrown where the app's own code runs outside a frame — a key handler, a
187
+ * timer — which would otherwise reach neither `run`'s caller nor the
188
+ * terminal's hand-back.
189
+ */
190
+ fail(error) {
191
+ this.end({ kind: "failed", error });
192
+ }
193
+ // --- the terminal ---
194
+ enter() {
195
+ this.host.setRawMode(true);
196
+ this.host.write(this.surface.enter + HIDE_CURSOR);
197
+ }
198
+ leave() {
199
+ this.host.write(RESET_STYLE + SHOW_CURSOR + this.surface.leave(this.rows));
200
+ this.host.setRawMode(false);
201
+ }
202
+ end(outcome) {
203
+ const phase = this._phase;
204
+ if (phase === "idle" || phase === "stopped")
205
+ return;
206
+ this._phase = "stopped";
207
+ for (const unsubscribe of this.subscriptions)
208
+ unsubscribe();
209
+ this.subscriptions = [];
210
+ // A suspended app handed the terminal back when it suspended.
211
+ if (phase === "running")
212
+ this.leave();
213
+ this.host.stop();
214
+ this.settle(outcome);
215
+ }
216
+ // [LAW:no-silent-failure] A frame that throws ends the app with that
217
+ // error: the terminal is handed back, and `run` rejects with it.
218
+ paint() {
219
+ try {
220
+ this.draw();
221
+ }
222
+ catch (error) {
223
+ this.end({ kind: "failed", error });
224
+ }
225
+ }
226
+ draw() {
227
+ const options = this.console.options;
228
+ const { maxWidth: cols } = options;
229
+ const height = { rows: options.height.rows, exact: this.surface.exact };
230
+ const lines = Segment.splitLines(this.view().render({ ...options, height }));
231
+ // The app set the budget, so the app shapes what comes back: no deeper
232
+ // than the terminal — a taller frame would scroll the rows `home` counts
233
+ // back over — and, as a region, exactly that deep. No row wider than the
234
+ // terminal either: one that soft-wrapped would push every row below it
235
+ // down a row the frame does not know about.
236
+ const frame = fitHeight(lines.slice(0, height.rows), height).map((line) => Segment.adjustLineLength(line, cols, undefined, false));
237
+ const painted = Math.min(this.surface.painted(frame.length, this.rows), height.rows);
238
+ // Each row is erased as it is reached rather than the frame cleared
239
+ // first, so no blank screen shows between two frames. The erase leads
240
+ // its row: after a row that fills the width, the cursor sits on its last
241
+ // cell, and an erase there would take it.
242
+ const destination = this.console.destination;
243
+ const body = Array.from({ length: painted }, (_, row) => ERASE_LINE + segmentsToString(frame[row] ?? [], destination)).join("\n");
244
+ // Rows blanked below the frame are not the frame's: the cursor goes back
245
+ // up to its last row, so the next frame and the program's next line start
246
+ // from the frame's own height.
247
+ const blanked = painted - Math.max(frame.length, 1);
248
+ const back = blanked > 0 ? `\x1b[${blanked}A` : "";
249
+ this.host.write(this.surface.home(this.rows) + body + back);
250
+ this._frame = frame;
251
+ this.rows = frame.length;
252
+ for (const handler of [...this.paintHandlers])
253
+ handler(frame);
254
+ }
255
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * hostEnvironment — a `TerminalHost` as the `ConsoleEnvironment` a `Console`
3
+ * consults, so a console writing through a host asks that host every question
4
+ * it has about its destination.
5
+ *
6
+ * [LAW:one-source-of-truth] A console asks its destination four things: where
7
+ * bytes go, is it a terminal, how big is it, and — through the environment —
8
+ * what colours it draws. A bare sink answers only the first, and the other
9
+ * three fall to the ambient `process`: some other stream's answer in node,
10
+ * none in a browser, where a console on xterm.js saw an unnamed TTY and drew
11
+ * 16 colours. Handing the console the whole environment keeps all four
12
+ * answers the host's.
13
+ *
14
+ * The host's size is `size()`, so the environment handed on carries no
15
+ * `COLUMNS` or `LINES`: a console reads those ahead of the stream, and an
16
+ * exported `COLUMNS` in the shell that launched a node host would pin the
17
+ * console's width while the terminal it draws on is resized. The stream's
18
+ * `columns` and `rows` are getters, so a console that was not given a fixed
19
+ * size follows the host through resizes.
20
+ */
21
+ import type { ConsoleEnvironment, ConsoleStream } from "../core/console.js";
22
+ import type { TerminalHost } from "./terminal-host.js";
23
+ export declare function hostEnvironment(host: TerminalHost): ConsoleEnvironment & {
24
+ readonly stdout: ConsoleStream;
25
+ readonly stderr: ConsoleStream;
26
+ };