@promptctl/rich-js 0.27.0 → 0.29.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.
Files changed (185) hide show
  1. package/README.md +7 -6
  2. package/THIRD-PARTY-NOTICES +42 -0
  3. package/dist/core/animation.d.ts +65 -46
  4. package/dist/core/animation.js +91 -67
  5. package/dist/core/ansi.d.ts +19 -12
  6. package/dist/core/ansi.js +114 -36
  7. package/dist/core/bidi-data.d.ts +14 -0
  8. package/dist/core/bidi-data.js +385 -0
  9. package/dist/core/bidi.d.ts +89 -0
  10. package/dist/core/bidi.js +615 -0
  11. package/dist/core/box.d.ts +9 -1
  12. package/dist/core/box.js +11 -0
  13. package/dist/core/cell-widths.d.ts +3 -2
  14. package/dist/core/cell-widths.js +93 -93
  15. package/dist/core/cells.d.ts +20 -0
  16. package/dist/core/cells.js +94 -6
  17. package/dist/core/clock.d.ts +1 -1
  18. package/dist/core/color.d.ts +56 -57
  19. package/dist/core/color.js +79 -81
  20. package/dist/core/console.d.ts +24 -7
  21. package/dist/core/console.js +27 -8
  22. package/dist/core/control-codes.d.ts +61 -0
  23. package/dist/core/control-codes.js +157 -0
  24. package/dist/core/easing.d.ts +40 -40
  25. package/dist/core/easing.js +40 -40
  26. package/dist/core/env.d.ts +6 -0
  27. package/dist/core/env.js +9 -1
  28. package/dist/core/export-html.js +5 -4
  29. package/dist/core/export-lines.d.ts +13 -11
  30. package/dist/core/export-lines.js +19 -15
  31. package/dist/core/export-svg.d.ts +3 -3
  32. package/dist/core/export-svg.js +10 -9
  33. package/dist/core/json.js +9 -2
  34. package/dist/core/markup.js +5 -4
  35. package/dist/core/memo.d.ts +1 -1
  36. package/dist/core/memo.js +1 -1
  37. package/dist/core/oklch.d.ts +14 -14
  38. package/dist/core/oklch.js +10 -10
  39. package/dist/core/orient.d.ts +68 -0
  40. package/dist/core/orient.js +224 -0
  41. package/dist/core/osc8.d.ts +12 -5
  42. package/dist/core/osc8.js +14 -7
  43. package/dist/core/paint.d.ts +9 -9
  44. package/dist/core/paint.js +3 -3
  45. package/dist/core/pretty.d.ts +1 -1
  46. package/dist/core/pretty.js +23 -19
  47. package/dist/core/protocol.d.ts +47 -26
  48. package/dist/core/protocol.js +31 -23
  49. package/dist/core/render.d.ts +19 -14
  50. package/dist/core/render.js +30 -17
  51. package/dist/core/seal.d.ts +26 -0
  52. package/dist/core/seal.js +75 -0
  53. package/dist/core/segment.d.ts +12 -4
  54. package/dist/core/segment.js +16 -6
  55. package/dist/core/strip.d.ts +83 -83
  56. package/dist/core/strip.js +70 -70
  57. package/dist/core/style.d.ts +46 -18
  58. package/dist/core/style.js +92 -56
  59. package/dist/core/text-order.d.ts +51 -0
  60. package/dist/core/text-order.js +50 -0
  61. package/dist/core/text.d.ts +12 -2
  62. package/dist/core/text.js +136 -90
  63. package/dist/core/timeline.d.ts +5 -5
  64. package/dist/core/timeline.js +11 -11
  65. package/dist/core/tween.d.ts +6 -6
  66. package/dist/core/tween.js +10 -10
  67. package/dist/core/view.d.ts +5 -5
  68. package/dist/core/view.js +15 -14
  69. package/dist/core/wrap.d.ts +24 -6
  70. package/dist/core/wrap.js +82 -16
  71. package/dist/host/app.d.ts +50 -9
  72. package/dist/host/app.js +120 -15
  73. package/dist/host/host-environment.d.ts +2 -2
  74. package/dist/host/host-environment.js +2 -2
  75. package/dist/host/index.d.ts +3 -2
  76. package/dist/host/index.js +1 -0
  77. package/dist/host/input.d.ts +42 -9
  78. package/dist/host/input.js +91 -7
  79. package/dist/host/terminal-colors.d.ts +18 -0
  80. package/dist/host/terminal-colors.js +46 -0
  81. package/dist/host/terminal-host.d.ts +4 -3
  82. package/dist/host/terminal-host.js +1 -1
  83. package/dist/index.d.ts +17 -16
  84. package/dist/index.js +11 -11
  85. package/dist/node/prompt.d.ts +1 -1
  86. package/dist/node/prompt.js +1 -1
  87. package/dist/node/terminal-host.d.ts +1 -1
  88. package/dist/node/traceback.js +2 -2
  89. package/dist/renderables/change.d.ts +1 -3
  90. package/dist/renderables/change.js +6 -8
  91. package/dist/renderables/effect.d.ts +88 -87
  92. package/dist/renderables/effect.js +124 -114
  93. package/dist/renderables/effects.d.ts +105 -76
  94. package/dist/renderables/effects.js +309 -176
  95. package/dist/renderables/embed.d.ts +1 -1
  96. package/dist/renderables/embed.js +2 -2
  97. package/dist/renderables/flexStrip.d.ts +7 -7
  98. package/dist/renderables/flexStrip.js +15 -15
  99. package/dist/renderables/frames.d.ts +7 -16
  100. package/dist/renderables/frames.js +12 -27
  101. package/dist/renderables/glyph-effect.d.ts +22 -23
  102. package/dist/renderables/glyph-effect.js +43 -41
  103. package/dist/renderables/glyph-effects.d.ts +33 -14
  104. package/dist/renderables/glyph-effects.js +90 -22
  105. package/dist/renderables/html-entities.d.ts +1 -1
  106. package/dist/renderables/html-entities.js +1 -1
  107. package/dist/renderables/lifecycle.d.ts +8 -10
  108. package/dist/renderables/lifecycle.js +17 -18
  109. package/dist/renderables/live.d.ts +2 -2
  110. package/dist/renderables/live.js +7 -7
  111. package/dist/renderables/markdown-parse.js +3 -3
  112. package/dist/renderables/markdown.js +4 -2
  113. package/dist/renderables/panel.js +7 -5
  114. package/dist/renderables/progress.d.ts +1 -1
  115. package/dist/renderables/progress.js +1 -1
  116. package/dist/renderables/progressBar.d.ts +3 -3
  117. package/dist/renderables/progressBar.js +21 -18
  118. package/dist/renderables/prompt.d.ts +1 -1
  119. package/dist/renderables/scroll.d.ts +1 -1
  120. package/dist/renderables/scroll.js +3 -3
  121. package/dist/renderables/spinner.d.ts +4 -4
  122. package/dist/renderables/spinner.js +10 -10
  123. package/dist/renderables/syntax.d.ts +2 -2
  124. package/dist/renderables/syntax.js +9 -4
  125. package/dist/renderables/table.js +5 -4
  126. package/dist/renderables/traceback.d.ts +1 -1
  127. package/dist/renderables/traceback.js +1 -1
  128. package/dist/renderables/tree.js +3 -3
  129. package/dist/template-bindings/animation-funcs.d.ts +60 -29
  130. package/dist/template-bindings/animation-funcs.js +126 -43
  131. package/dist/template-bindings/color-funcs.d.ts +14 -14
  132. package/dist/template-bindings/color-funcs.js +25 -25
  133. package/dist/template-bindings/index.d.ts +16 -14
  134. package/dist/template-bindings/index.js +17 -14
  135. package/dist/template-bindings/palette-funcs.d.ts +2 -2
  136. package/dist/template-bindings/palette-funcs.js +4 -4
  137. package/dist/template-bindings/style-funcs.d.ts +5 -5
  138. package/dist/template-bindings/style-funcs.js +16 -16
  139. package/dist/themes/{transpose.d.ts → adjust.d.ts} +23 -23
  140. package/dist/themes/{transpose.js → adjust.js} +28 -28
  141. package/dist/themes/buildPalette.d.ts +6 -5
  142. package/dist/themes/buildPalette.js +22 -22
  143. package/dist/themes/colorMath.d.ts +16 -16
  144. package/dist/themes/colorMath.js +32 -32
  145. package/dist/themes/data/ansi-tables.d.ts +2 -2
  146. package/dist/themes/data/ansi-tables.js +2 -2
  147. package/dist/themes/data/index.js +1 -4
  148. package/dist/themes/data/textual-ansi.js +0 -143
  149. package/dist/themes/data/types.d.ts +4 -4
  150. package/dist/themes/data/types.js +2 -2
  151. package/dist/themes/palette.d.ts +12 -12
  152. package/dist/themes/palette.js +16 -16
  153. package/dist/themes/ramp.d.ts +17 -17
  154. package/dist/themes/ramp.js +12 -12
  155. package/dist/themes/registry.d.ts +6 -8
  156. package/dist/themes/registry.js +6 -8
  157. package/dist/themes/terminalThemes.d.ts +4 -11
  158. package/dist/themes/terminalThemes.js +31 -47
  159. package/dist/widgets/button.d.ts +3 -3
  160. package/dist/widgets/button.js +8 -8
  161. package/dist/widgets/checkbox.d.ts +2 -2
  162. package/dist/widgets/checkbox.js +4 -4
  163. package/dist/widgets/dropdown.d.ts +3 -3
  164. package/dist/widgets/dropdown.js +7 -5
  165. package/dist/widgets/event-router.d.ts +6 -6
  166. package/dist/widgets/event-router.js +2 -2
  167. package/dist/widgets/focus-manager.d.ts +3 -3
  168. package/dist/widgets/focus-manager.js +3 -3
  169. package/dist/widgets/index.d.ts +2 -2
  170. package/dist/widgets/index.js +1 -1
  171. package/dist/widgets/ink.d.ts +3 -3
  172. package/dist/widgets/ink.js +4 -4
  173. package/dist/widgets/slider.d.ts +2 -2
  174. package/dist/widgets/static-item.d.ts +2 -2
  175. package/dist/widgets/text-input.d.ts +2 -2
  176. package/dist/widgets/text-input.js +5 -5
  177. package/dist/widgets/themed-widget.js +4 -4
  178. package/dist/widgets/toggle.d.ts +4 -4
  179. package/dist/widgets/toggle.js +6 -6
  180. package/dist/widgets/types.d.ts +6 -6
  181. package/dist/widgets/types.js +2 -2
  182. package/dist/widgets/widget-app.d.ts +10 -10
  183. package/dist/widgets/widget-app.js +8 -8
  184. package/dist/widgets/widget-base.d.ts +2 -2
  185. package/package.json +5 -4
package/dist/host/app.js CHANGED
@@ -31,21 +31,22 @@
31
31
  * — a focused field in a `WidgetApp` included — can swallow them.
32
32
  */
33
33
  import { frameRate, systemClock } from "../core/clock.js";
34
+ import { ColorDepth, resolveTerminal } from "../core/color.js";
34
35
  import { Console } from "../core/console.js";
35
36
  import { Segment } from "../core/segment.js";
36
- import { SurfacePainter } from "../core/paint.js";
37
+ import { SurfaceRenderer } from "../core/paint.js";
37
38
  import { fitHeight, render } from "../core/protocol.js";
38
39
  import { Timeline } from "../core/timeline.js";
39
40
  import { drawView, frameNow, hostView, undrawn } from "../core/view.js";
40
41
  import { hostEnvironment } from "./host-environment.js";
41
42
  import { InputParser, keyName, parseKeyName } from "./input.js";
42
- // [LAW:one-source-of-truth] How a frame draws — its glyphs, the style names
43
- // it knows, what happens to a style it cannot resolve, markup and
44
- // highlighting — is the console's to say, so App takes those options as the
43
+ // [LAW:one-source-of-truth] How a frame draws — its glyphs, the terminal its
44
+ // colors resolve on, the style names it knows, what happens to a style it
45
+ // cannot resolve, markup and highlighting — is the console's to say, so App takes those options as the
45
46
  // console declares them. This list is both the type and what is copied: the
46
- // host alone decides the console's size, colours and sink, so no other
47
+ // host alone decides the console's size, colors and sink, so no other
47
48
  // property of the options object reaches it, whatever the object carries.
48
- const DRAW_OPTIONS = ["asciiOnly", "reducedMotion", "theme", "onStyleError", "markup", "highlight", "highlighter"];
49
+ const DRAW_OPTIONS = ["asciiOnly", "reducedMotion", "textOrder", "terminal", "theme", "onStyleError", "markup", "highlight", "highlighter"];
49
50
  function drawOptions(options) {
50
51
  const picked = {};
51
52
  const copy = (key) => {
@@ -68,6 +69,12 @@ const POINTER = {
68
69
  // The sideways wheel is not here: nothing scrolls by columns, inside an app or
69
70
  // around it.
70
71
  const WHEEL_LINES = { scroll_up: -1, scroll_down: 1 };
72
+ // Primary Device Attributes. Every VT100 descendant answers it, and a terminal
73
+ // answers in the order it was asked, so its reply, sent after an ask's queries,
74
+ // says every reply to them that is coming has come.
75
+ const DA1 = "\x1b[c";
76
+ const isDa1Reply = (reply) => reply.kind === "csi" && reply.body.startsWith("?") && reply.body.endsWith("c");
77
+ const DEFAULT_ASK_TIMEOUT = 0.5;
71
78
  const isWheelTurn = (event) => event.type in WHEEL_LINES;
72
79
  export class App {
73
80
  host;
@@ -84,12 +91,21 @@ export class App {
84
91
  // What the frame on screen says about when it changes, on the clock.
85
92
  // Before a frame, nothing.
86
93
  said = undrawn(0);
87
- // The one wait for the frame's `nextChange`, armed after each frame.
94
+ // The one wait for the frame's `validUntil`, armed after each frame.
88
95
  waking = null;
89
96
  // [LAW:one-source-of-truth] The host is the console's whole environment:
90
- // its size, its colours, where bytes go. Every frame renders with this
97
+ // its size, its colors, where bytes go. Every frame renders with this
91
98
  // console's options and is encoded for its destination.
92
99
  console;
100
+ drawing;
101
+ // [LAW:single-enforcer] One ask at a time, through the one input reader:
102
+ // the next waits on the last, and `leave` ends the one in flight.
103
+ asking = null;
104
+ asked = Promise.resolve();
105
+ // DA1 replies still owed to asks that timed out. A terminal answers in the
106
+ // order it was asked, so every reply before the last of them belongs to an
107
+ // ask that has ended, and none reaches the ask after it.
108
+ owedDa1 = 0;
93
109
  _phase = "idle";
94
110
  _frame = [];
95
111
  painter;
@@ -104,16 +120,88 @@ export class App {
104
120
  settle = () => { };
105
121
  constructor(options) {
106
122
  this.host = options.host;
107
- this.input = new InputParser({ key: (press) => this.key(press), mouse: (event) => this.mouse(event) }, options.host.inputChunks);
123
+ this.input = new InputParser({ key: (press) => this.key(press), mouse: (event) => this.mouse(event), reply: (reply) => this.reply(reply) }, options.host.inputChunks);
108
124
  this.surface = options.surface;
109
- this.painter = new SurfacePainter(options.surface, (bytes) => this.host.write(bytes));
125
+ this.painter = new SurfaceRenderer(options.surface, (bytes) => this.host.write(bytes));
110
126
  this.view = hostView(options.view);
111
127
  this._clock = options.clock ?? systemClock();
112
128
  this._epoch = this._clock.now();
113
129
  this._timeline = options.timeline ?? Timeline.play(this._epoch);
114
130
  this._rate = options.rate ?? DEFAULT_RATE;
115
131
  this.quitKeys = new Set((options.quitKeys ?? DEFAULT_QUIT_KEYS).map(parseKeyName));
116
- this.console = new Console({ ...drawOptions(options), environment: hostEnvironment(options.host) });
132
+ this.drawing = drawOptions(options);
133
+ this.console = this.drawingConsole();
134
+ }
135
+ /**
136
+ * The terminal the app draws on: the colors a cell that sets none is drawn
137
+ * in, and the sixteen an ANSI color names — as given, or as the color
138
+ * depth assumes (`resolveTerminal`).
139
+ */
140
+ get terminal() {
141
+ return resolveTerminal(this.console.options.terminal, this.console.colorSystem ?? ColorDepth.TRUECOLOR);
142
+ }
143
+ /** Draw on `terminal` from the next frame on — what `terminalColors` learned, say. */
144
+ set terminal(terminal) {
145
+ // [LAW:one-source-of-truth] The console is the one holder of how a frame
146
+ // draws, so the terminal changes by a new console, not beside the old one.
147
+ this.drawing = { ...this.drawing, terminal };
148
+ this.console = this.drawingConsole();
149
+ this.refresh();
150
+ }
151
+ /**
152
+ * Ask the terminal: write `queries`, then DA1, and resolve with the replies
153
+ * heard before DA1's, or before the timeout or the app handing the terminal
154
+ * back. It rejects only when writing to the terminal throws. Asks run one
155
+ * after another, and only while the app runs: it holds raw mode and reads
156
+ * the input, so a reply reaches the ask and never a key handler. A reply no
157
+ * ask is waiting for is dropped. `queries` must not ask for DA1 itself: its
158
+ * reply would end the ask early.
159
+ */
160
+ ask(queries, options = {}) {
161
+ // An ask before the app runs is a mistake in the program; one after it
162
+ // stopped ends `left`, so the error that stopped it is the one heard.
163
+ if (this._phase === "idle")
164
+ throw new Error("App.ask: an app asks its terminal once it runs, and this one has not started");
165
+ const timeout = options.timeout ?? DEFAULT_ASK_TIMEOUT;
166
+ if (!(timeout > 0 && Number.isFinite(timeout)))
167
+ throw new RangeError(`App.ask: a timeout is a positive, finite number of seconds, not ${timeout}`);
168
+ const turn = this.asked.then(() => this.askNow(queries, timeout));
169
+ // One ask's failure is its caller's; the asks queued after it still run.
170
+ this.asked = turn.catch(() => undefined);
171
+ return turn;
172
+ }
173
+ // The one way the app builds the console it draws with.
174
+ drawingConsole() {
175
+ return new Console({ ...this.drawing, environment: hostEnvironment(this.host) });
176
+ }
177
+ askNow(queries, timeout) {
178
+ return new Promise((resolve) => {
179
+ if (this._phase !== "running")
180
+ return resolve({ ended: "left", replies: [] });
181
+ if (!this.host.writesToTerminal)
182
+ return resolve({ ended: "unasked", replies: [] });
183
+ // Written before the ask takes the input, so a write that throws leaves
184
+ // no ask in flight and no timer armed.
185
+ this.host.write(queries.join("") + DA1);
186
+ const replies = [];
187
+ const timer = this._clock.at(this._clock.now() + timeout, () => {
188
+ this.owedDa1++;
189
+ asking.finish("timeout");
190
+ });
191
+ const asking = {
192
+ replies,
193
+ finish: (ended) => {
194
+ timer();
195
+ this.asking = null;
196
+ // [LAW:single-enforcer] Every way an ask ends goes through here, so
197
+ // what the parser held for its replies is settled on every one.
198
+ this.input.awaitReplies(false);
199
+ resolve({ ended, replies });
200
+ },
201
+ };
202
+ this.asking = asking;
203
+ this.input.awaitReplies(true);
204
+ });
117
205
  }
118
206
  get phase() {
119
207
  return this._phase;
@@ -238,6 +326,8 @@ export class App {
238
326
  return;
239
327
  this._phase = "suspended";
240
328
  this.tick();
329
+ // What is half-read belongs to no one once the terminal is handed back.
330
+ this.input.reset();
241
331
  this.leave();
242
332
  await this.host.suspend();
243
333
  // Stopped while suspended — by a signal, say: the terminal is already
@@ -258,7 +348,7 @@ export class App {
258
348
  return () => this.paintHandlers.delete(handler);
259
349
  }
260
350
  /**
261
- * Hear every key the app does not quit on, by the `KeyPress` the terminal
351
+ * Hear every key the app does not quit on, by the `KeyEvent` the terminal
262
352
  * reported; `keyName` names it. A handler that throws fails the app.
263
353
  */
264
354
  onKey(handler) {
@@ -306,6 +396,9 @@ export class App {
306
396
  this.host.write(POINTER[this.surface].on);
307
397
  }
308
398
  leave() {
399
+ this.asking?.finish("left");
400
+ // Whatever answers the terminal sends from here reach whoever holds it next.
401
+ this.owedDa1 = 0;
309
402
  this.host.write(POINTER[this.surface].off);
310
403
  this.painter.handBack();
311
404
  this.host.setRawMode(false);
@@ -332,6 +425,18 @@ export class App {
332
425
  this.settle(outcome);
333
426
  }
334
427
  // --- input ---
428
+ reply(reply) {
429
+ if (this.owedDa1 > 0) {
430
+ if (isDa1Reply(reply))
431
+ this.owedDa1--;
432
+ return;
433
+ }
434
+ if (this.asking === null)
435
+ return;
436
+ if (isDa1Reply(reply))
437
+ return this.asking.finish("answered");
438
+ this.asking.replies.push(reply);
439
+ }
335
440
  key(press) {
336
441
  if (this.quitKeys.has(keyName(press)))
337
442
  return this.stop();
@@ -383,8 +488,8 @@ export class App {
383
488
  // says whether the view moves.
384
489
  tick() {
385
490
  const running = this._phase === "running";
386
- const { t, nextChange } = this.said;
387
- const wanted = running && nextChange === t;
491
+ const { t, validUntil } = this.said;
492
+ const wanted = running && validUntil === t;
388
493
  if (this.ticking !== null && (!wanted || this.ticking.rate.perSecond !== this._rate.perSecond)) {
389
494
  this.ticking.stop();
390
495
  this.ticking = null;
@@ -393,7 +498,7 @@ export class App {
393
498
  this.ticking = { rate: this._rate, stop: this._clock.every(this._rate, () => this.paint()) };
394
499
  }
395
500
  this.waking?.();
396
- this.waking = running && t < nextChange && nextChange < Number.POSITIVE_INFINITY ? this._clock.at(nextChange, () => this.paint()) : null;
501
+ this.waking = running && t < validUntil && validUntil < Number.POSITIVE_INFINITY ? this._clock.at(validUntil, () => this.paint()) : null;
397
502
  }
398
503
  // [LAW:no-silent-failure] A frame that throws ends the app with that
399
504
  // error: the terminal is handed back, and `run` rejects with it.
@@ -5,10 +5,10 @@
5
5
  *
6
6
  * [LAW:one-source-of-truth] A console asks its destination four things: where
7
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
8
+ * what colors it draws. A bare sink answers only the first, and the other
9
9
  * three fall to the ambient `process`: some other stream's answer in node,
10
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
11
+ * 16 colors. Handing the console the whole environment keeps all four
12
12
  * answers the host's.
13
13
  *
14
14
  * The host's size is `size()`, so the environment handed on carries no
@@ -5,10 +5,10 @@
5
5
  *
6
6
  * [LAW:one-source-of-truth] A console asks its destination four things: where
7
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
8
+ * what colors it draws. A bare sink answers only the first, and the other
9
9
  * three fall to the ambient `process`: some other stream's answer in node,
10
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
11
+ * 16 colors. Handing the console the whole environment keeps all four
12
12
  * answers the host's.
13
13
  *
14
14
  * The host's size is `size()`, so the environment handed on carries no
@@ -2,7 +2,8 @@ export { BrowserTerminalHost } from "./terminal-host.js";
2
2
  export type { TerminalHost, TerminalSize, DataHandler, InputChunks, ResizeHandler, ExitHandler, BrowserTerminalHostOptions, XtermTerminal, XtermDisposable, XtermResizeEvent, } from "./terminal-host.js";
3
3
  export { hostEnvironment } from "./host-environment.js";
4
4
  export { App } from "./app.js";
5
- export type { AppMouseEvent, AppOptions, AppPhase, Wheel } from "./app.js";
5
+ export type { AppMouseEvent, AppOptions, AppPhase, Asked, AskOptions, WheelEvent } from "./app.js";
6
+ export { terminalColors } from "./terminal-colors.js";
6
7
  export type { Surface } from "../core/paint.js";
7
8
  export { keyName } from "./input.js";
8
- export type { KeyPress, ScreenMouseEvent } from "./input.js";
9
+ export type { KeyEvent, TerminalMouseEvent, TerminalReply } from "./input.js";
@@ -1,4 +1,5 @@
1
1
  export { BrowserTerminalHost } from "./terminal-host.js";
2
2
  export { hostEnvironment } from "./host-environment.js";
3
3
  export { App } from "./app.js";
4
+ export { terminalColors } from "./terminal-colors.js";
4
5
  export { keyName } from "./input.js";
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Terminal input: the bytes a terminal sends for keys and the pointer,
3
- * parsed into `KeyPress` and `ScreenMouseEvent` values, and the one name
3
+ * parsed into `KeyEvent` and `TerminalMouseEvent` values, and the one name
4
4
  * each key goes by.
5
5
  *
6
6
  * [LAW:single-enforcer] Escape-sequence parsing lives only here. `App` owns
@@ -13,8 +13,9 @@
13
13
  * "are we mid-sequence" mode flag — the buffer head carries that state, so on
14
14
  * a host whose chunks can split a key, a UTF-8 character or an escape
15
15
  * sequence split across two chunks is completed by the second. What a chunk
16
- * end means is the host's `InputChunks`, the one input to the parser that is
17
- * not a byte, and it decides only what becomes of an unfinished ESC.
16
+ * end means is the host's `InputChunks`, and whether an ask awaits a reply is
17
+ * what its owner last told `awaitReplies`: the two inputs to the parser that
18
+ * are not bytes, and both decide only what becomes of an unfinished ESC.
18
19
  *
19
20
  * An unfinished escape sequence is ambiguous: `ESC` alone is the Escape key
20
21
  * or the start of a sequence, and `ESC [` is Alt+[ or the start of a CSI.
@@ -29,10 +30,19 @@
29
30
  * lone ESC is also settled early by a next chunk that opens with anything but
30
31
  * `[` or `O`, because a busy worker can queue the next key behind it.
31
32
  * `flush()` settles the hold synchronously.
33
+ *
34
+ * `ESC ]`, `ESC P` and `ESC _` open the control strings a terminal replies
35
+ * in, and are also Alt+], Alt+P and Alt+_. A reply can arrive split only on
36
+ * a split host while an ask awaits one (`replyMayArrive`), and only then is an
37
+ * unterminated one, or a lone ESC that may open one, held — past the escape
38
+ * delay, until `awaitReplies(false)` arms the delay afresh. Otherwise it is Alt
39
+ * on its key at once. A terminated one is a reply either way, and one cut off
40
+ * by an ESC that is not ST was never a reply, and is Alt on its key, as with
41
+ * none awaited.
32
42
  */
33
43
  import type { InputChunks } from "./terminal-host.js";
34
44
  /** A key as the terminal reported it: plain data, with no say over who hears it. */
35
- export interface KeyPress {
45
+ export interface KeyEvent {
36
46
  readonly key: string;
37
47
  /** The printable character the key types, or "" for a key that types none. */
38
48
  readonly character: string;
@@ -40,7 +50,7 @@ export interface KeyPress {
40
50
  readonly ctrl: boolean;
41
51
  readonly meta: boolean;
42
52
  }
43
- export type ScreenMouseEvent = {
53
+ export type TerminalMouseEvent = {
44
54
  [T in MouseEventType]: MouseEventOf<T>;
45
55
  }[MouseEventType];
46
56
  type MouseEventType = "mouse_down" | "mouse_up" | "mouse_move" | "scroll_up" | "scroll_down" | "scroll_left" | "scroll_right";
@@ -60,11 +70,23 @@ interface MouseEventOf<Type extends string> {
60
70
  * `ctrl+c`, `shift+tab`, `meta+left`. Shift is not named on a character,
61
71
  * which already carries it: Shift+G is `G`.
62
72
  */
63
- export declare function keyName(press: KeyPress): string;
73
+ export declare function keyName(press: KeyEvent): string;
74
+ /**
75
+ * What a terminal sends back when it is asked something: an OSC, DCS or APC
76
+ * string, its introducer and terminator taken off (`11;rgb:1e1e/1e1e/1e1e`
77
+ * for an OSC 11 reply), or a CSI report with a private prefix, its final byte
78
+ * kept (`?62;22c` for DA1, `?2026;2$y` for DECRQM). No key is sent in either
79
+ * form, so neither is ever read as one.
80
+ */
81
+ export interface TerminalReply {
82
+ readonly kind: "osc" | "dcs" | "apc" | "csi";
83
+ readonly body: string;
84
+ }
64
85
  /** Where a parser delivers what it parses. */
65
86
  export interface InputSink {
66
- key(press: KeyPress): void;
67
- mouse(event: ScreenMouseEvent): void;
87
+ key(press: KeyEvent): void;
88
+ mouse(event: TerminalMouseEvent): void;
89
+ reply(reply: TerminalReply): void;
68
90
  }
69
91
  /**
70
92
  * `name`, if `keyName` can produce it: modifiers in its order, then a named
@@ -87,11 +109,22 @@ export declare class InputParser {
87
109
  private readonly chunks;
88
110
  private buffer;
89
111
  private escTimer;
112
+ private awaiting;
90
113
  constructor(sink: InputSink, chunks: InputChunks);
91
114
  /** Feed a chunk of bytes (or a string of them) into the parser. */
92
115
  feed(chunk: Uint8Array | string): void;
93
116
  private drainAndHold;
94
- private hold;
117
+ private validity;
118
+ /**
119
+ * Whether an ask is awaiting the terminal's replies, told by whoever asks
120
+ * at the ask's start and end. Only while one is does an unfinished
121
+ * `ESC ]`, `ESC P` or `ESC _` open a reply still arriving; otherwise it is
122
+ * Alt on that key. The wait's end restarts the escape delay for what is
123
+ * held, on a split host, the only one that holds.
124
+ */
125
+ awaitReplies(awaiting: boolean): void;
126
+ private replyMayArrive;
127
+ private continuesEscape;
95
128
  private settleChunk;
96
129
  private drainFully;
97
130
  /** Settle a held escape sequence now, as the end of a whole-key chunk. */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Terminal input: the bytes a terminal sends for keys and the pointer,
3
- * parsed into `KeyPress` and `ScreenMouseEvent` values, and the one name
3
+ * parsed into `KeyEvent` and `TerminalMouseEvent` values, and the one name
4
4
  * each key goes by.
5
5
  *
6
6
  * [LAW:single-enforcer] Escape-sequence parsing lives only here. `App` owns
@@ -13,8 +13,9 @@
13
13
  * "are we mid-sequence" mode flag — the buffer head carries that state, so on
14
14
  * a host whose chunks can split a key, a UTF-8 character or an escape
15
15
  * sequence split across two chunks is completed by the second. What a chunk
16
- * end means is the host's `InputChunks`, the one input to the parser that is
17
- * not a byte, and it decides only what becomes of an unfinished ESC.
16
+ * end means is the host's `InputChunks`, and whether an ask awaits a reply is
17
+ * what its owner last told `awaitReplies`: the two inputs to the parser that
18
+ * are not bytes, and both decide only what becomes of an unfinished ESC.
18
19
  *
19
20
  * An unfinished escape sequence is ambiguous: `ESC` alone is the Escape key
20
21
  * or the start of a sequence, and `ESC [` is Alt+[ or the start of a CSI.
@@ -29,6 +30,15 @@
29
30
  * lone ESC is also settled early by a next chunk that opens with anything but
30
31
  * `[` or `O`, because a busy worker can queue the next key behind it.
31
32
  * `flush()` settles the hold synchronously.
33
+ *
34
+ * `ESC ]`, `ESC P` and `ESC _` open the control strings a terminal replies
35
+ * in, and are also Alt+], Alt+P and Alt+_. A reply can arrive split only on
36
+ * a split host while an ask awaits one (`replyMayArrive`), and only then is an
37
+ * unterminated one, or a lone ESC that may open one, held — past the escape
38
+ * delay, until `awaitReplies(false)` arms the delay afresh. Otherwise it is Alt
39
+ * on its key at once. A terminated one is a reply either way, and one cut off
40
+ * by an ESC that is not ST was never a reply, and is Alt on its key, as with
41
+ * none awaited.
32
42
  */
33
43
  /**
34
44
  * The one name a key goes by — what a keymap is keyed by and what
@@ -136,6 +146,12 @@ const LANGLE = 0x3c; // <
136
146
  const O_BYTE = 0x4f; // O
137
147
  const M_UPPER = 0x4d; // M
138
148
  const M_LOWER = 0x6d; // m
149
+ const BEL = 0x07;
150
+ const BACKSLASH = 0x5c;
151
+ // The control strings a terminal answers in, by the byte after ESC: OSC, DCS, APC.
152
+ const STRING_REPLIES = { 0x5d: "osc", 0x50: "dcs", 0x5f: "apc" };
153
+ // A CSI whose parameters open with one of these is a report, never a key.
154
+ const PRIVATE_PREFIXES = new Set(["?", ">"]);
139
155
  // [LAW:single-enforcer] Shared encode/decode singletons. The parser
140
156
  // receives `Uint8Array | string` from the host and walks bytes through
141
157
  // its state machine; conversion happens at the two boundaries (string
@@ -198,6 +214,7 @@ export class InputParser {
198
214
  // [LAW:types-are-the-program] `setTimeout`/`clearTimeout` are universal
199
215
  // across node and browser; `setImmediate` is node-only.
200
216
  escTimer;
217
+ awaiting = false;
201
218
  constructor(sink, chunks) {
202
219
  this.sink = sink;
203
220
  this.chunks = chunks;
@@ -213,7 +230,9 @@ export class InputParser {
213
230
  // Any other next chunk means Escape was its own key, even when the hold
214
231
  // timer has not run yet: a busy worker can queue the next key ahead of it,
215
232
  // and joining them would read Escape then G as Alt+G (rich-host-input-0c4f).
216
- const escaped = this.holdingEscape() && next.length > 0 && next[0] !== LBRACKET && next[0] !== O_BYTE;
233
+ // While a reply is awaited, a next chunk opening one of its control strings
234
+ // continues it too: a reply split right after its ESC.
235
+ const escaped = this.holdingEscape() && next.length > 0 && !this.continuesEscape(next[0]);
217
236
  this.cancelEscape();
218
237
  // The buffer is settled before the sink hears Escape, so a sink that
219
238
  // resets the parser on it (an app stopping) leaves nothing to drain.
@@ -231,7 +250,7 @@ export class InputParser {
231
250
  this.drainFully();
232
251
  }
233
252
  finally {
234
- this.hold();
253
+ this.validity();
235
254
  }
236
255
  }
237
256
  // [LAW:no-ambient-temporal-coupling] Whatever drain leaves at an ESC is an
@@ -242,14 +261,41 @@ export class InputParser {
242
261
  // timer before it reads input queued behind a long frame, so the delay's end
243
262
  // settles the hold only after one more turn of the event loop has read what
244
263
  // was waiting. The parser holds one timer, so arming it replaces any other.
245
- hold() {
264
+ // While a reply may arrive the delay settles no hold that may open one: a
265
+ // reply split over a slow link may take longer than it. The wait's end arms
266
+ // it afresh (`awaitReplies`), so what is held then gets the whole delay.
267
+ validity() {
246
268
  this.cancelEscape();
247
269
  if (this.buffer[0] !== ESC)
248
270
  return;
249
271
  this.escTimer = setTimeout(() => {
250
- this.escTimer = setTimeout(() => this.flush(), 0);
272
+ this.escTimer = setTimeout(() => {
273
+ if (!this.replyMayArrive() || !opensString(this.buffer))
274
+ this.flush();
275
+ }, 0);
251
276
  }, ESCAPE_DELAY_MS);
252
277
  }
278
+ /**
279
+ * Whether an ask is awaiting the terminal's replies, told by whoever asks
280
+ * at the ask's start and end. Only while one is does an unfinished
281
+ * `ESC ]`, `ESC P` or `ESC _` open a reply still arriving; otherwise it is
282
+ * Alt on that key. The wait's end restarts the escape delay for what is
283
+ * held, on a split host, the only one that holds.
284
+ */
285
+ awaitReplies(awaiting) {
286
+ this.awaiting = awaiting;
287
+ if (!awaiting && this.chunks === "split")
288
+ this.validity();
289
+ }
290
+ // [LAW:single-enforcer] The one reading of whether an unfinished control
291
+ // string may be a reply still arriving: only a split host splits one, and
292
+ // only an ask awaits one.
293
+ replyMayArrive() {
294
+ return this.chunks === "split" && this.awaiting;
295
+ }
296
+ continuesEscape(byte) {
297
+ return byte === LBRACKET || byte === O_BYTE || (STRING_REPLIES[byte] !== undefined && this.replyMayArrive());
298
+ }
253
299
  // A whole-key chunk has ended, so an ESC still at the buffer head opens no
254
300
  // sequence, and is read as `reissue` reads it. Each key is taken off the
255
301
  // buffer before the sink hears it, so no byte of one chunk outlives it.
@@ -311,6 +357,8 @@ export class InputParser {
311
357
  this.sink.key(result.event);
312
358
  else if (result.kind === "mouse")
313
359
  this.sink.mouse(result.event);
360
+ else if (result.kind === "reply")
361
+ this.sink.reply(result.event);
314
362
  // skip → drop bytes silently
315
363
  }
316
364
  }
@@ -377,6 +425,15 @@ export class InputParser {
377
425
  // ESC [ ... → CSI
378
426
  if (b1 === LBRACKET)
379
427
  return this.consumeCSI(buf);
428
+ const string = STRING_REPLIES[b1];
429
+ if (string !== undefined) {
430
+ const result = consumeString(buf, string);
431
+ // Unterminated where no reply may arrive, it was never a reply: Alt on
432
+ // its key, and what followed is read on its own.
433
+ if (result.kind === "incomplete" && !this.replyMayArrive())
434
+ return { kind: "key", bytes: 2, event: altKey(b1) };
435
+ return result;
436
+ }
380
437
  // ESC O X → SS3 (F1–F4 / arrows on some terms)
381
438
  if (b1 === O_BYTE)
382
439
  return this.consumeSS3(buf);
@@ -555,7 +612,34 @@ function decodeMouseFromCb(cb, x, y, isPress) {
555
612
  ctrl,
556
613
  };
557
614
  }
615
+ // A lone ESC, or one opening a control string: the holds a reply may be.
616
+ function opensString(buf) {
617
+ return buf.length === 1 || STRING_REPLIES[buf[1]] !== undefined;
618
+ }
619
+ // A control string runs to its terminator, ST (`ESC \\`) or BEL, and holds
620
+ // any byte but ESC before it. Terminated, it is a reply whether or not one was
621
+ // awaited. Unterminated, it is unfinished, and `consumeEscape` decides by
622
+ // whether a reply may arrive if that means held or Alt on its key. An ESC that
623
+ // is not ST ends it before it closed: it was never a reply, so it is Alt on
624
+ // its key and what followed is read on its own, as with none awaited.
625
+ function consumeString(buf, kind) {
626
+ for (let i = 2; i < buf.length; i++) {
627
+ const b = buf[i];
628
+ if (b === BEL)
629
+ return { kind: "reply", bytes: i + 1, event: { kind, body: UTF8_DECODER.decode(buf.subarray(2, i)) } };
630
+ if (b !== ESC)
631
+ continue;
632
+ if (i + 1 === buf.length)
633
+ return { kind: "incomplete" };
634
+ if (buf[i + 1] !== BACKSLASH)
635
+ return { kind: "key", bytes: 2, event: altKey(buf[1]) };
636
+ return { kind: "reply", bytes: i + 2, event: { kind, body: UTF8_DECODER.decode(buf.subarray(2, i)) } };
637
+ }
638
+ return { kind: "incomplete" };
639
+ }
558
640
  function decodeCSI(params, final, bytes) {
641
+ if (PRIVATE_PREFIXES.has(params[0] ?? ""))
642
+ return { kind: "reply", bytes, event: { kind: "csi", body: params + final } };
559
643
  // Handle modifier suffixes like `1;2A` (shift+up). We split params on `;`.
560
644
  const parts = params.split(";");
561
645
  const first = parts[0] ?? "";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The terminal's own foreground and background, asked of it with OSC 10 and
3
+ * OSC 11 — the colors every contrast decision is measured against, which the
4
+ * library otherwise has to assume.
5
+ *
6
+ * [LAW:effects-at-boundaries] The ask is `App`'s, the one reader of its
7
+ * terminal's input; what is here is the query bytes and the parse of their
8
+ * replies, which stays pure.
9
+ */
10
+ import { TerminalColors } from "../core/color.js";
11
+ import type { App } from "./app.js";
12
+ /**
13
+ * Ask the terminal `app` runs on for its foreground and background, and return
14
+ * the terminal `app` draws on with them in place — its sixteen ANSI colors
15
+ * kept, since nothing asks for those. `undefined` when the terminal did not
16
+ * answer both, so the caller keeps what it had. Set it as `app.terminal`.
17
+ */
18
+ export declare function terminalColors(app: App): Promise<TerminalColors | undefined>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The terminal's own foreground and background, asked of it with OSC 10 and
3
+ * OSC 11 — the colors every contrast decision is measured against, which the
4
+ * library otherwise has to assume.
5
+ *
6
+ * [LAW:effects-at-boundaries] The ask is `App`'s, the one reader of its
7
+ * terminal's input; what is here is the query bytes and the parse of their
8
+ * replies, which stays pure.
9
+ */
10
+ import { ColorRgba, TerminalColors } from "../core/color.js";
11
+ import { pattern } from "../core/pattern.js";
12
+ // OSC 10 and OSC 11 with `?` ask for the dynamic foreground and background.
13
+ const QUERIES = ["\x1b]10;?\x1b\\", "\x1b]11;?\x1b\\"];
14
+ // XParseColor's `rgb:` form, 1–4 hex digits a channel, as xterm and every
15
+ // terminal that copies it answers, or `rgba:`, as urxvt answers with
16
+ // transparency on; the alpha says nothing about the color a cell is drawn in.
17
+ const RGB = pattern(/^rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})(?:\/[0-9a-f]{1,4})?$/i);
18
+ // A channel of n hex digits is a fraction of 16ⁿ − 1, scaled to 8 bits.
19
+ const channel = (hex) => Math.round((parseInt(hex, 16) / (16 ** hex.length - 1)) * 255);
20
+ /**
21
+ * The color an OSC 10 or OSC 11 reply reports, or `undefined` when `reply`
22
+ * is not that answer to `slot`.
23
+ */
24
+ function parseColorReply(reply, slot) {
25
+ if (reply.kind !== "osc")
26
+ return undefined;
27
+ const prefix = `${slot};`;
28
+ if (!reply.body.startsWith(prefix))
29
+ return undefined;
30
+ const match = RGB.exec(reply.body.slice(prefix.length));
31
+ return match === null ? undefined : new ColorRgba(channel(match[1]), channel(match[2]), channel(match[3]));
32
+ }
33
+ /**
34
+ * Ask the terminal `app` runs on for its foreground and background, and return
35
+ * the terminal `app` draws on with them in place — its sixteen ANSI colors
36
+ * kept, since nothing asks for those. `undefined` when the terminal did not
37
+ * answer both, so the caller keeps what it had. Set it as `app.terminal`.
38
+ */
39
+ export async function terminalColors(app) {
40
+ const { replies } = await app.ask(QUERIES);
41
+ const foreground = replies.map((reply) => parseColorReply(reply, 10)).find((color) => color !== undefined);
42
+ const background = replies.map((reply) => parseColorReply(reply, 11)).find((color) => color !== undefined);
43
+ if (foreground === undefined || background === undefined)
44
+ return undefined;
45
+ return new TerminalColors(background, foreground, app.terminal.ansiColors);
46
+ }
@@ -69,7 +69,8 @@ export interface TerminalHost {
69
69
  onData(handler: DataHandler): Unsubscribe;
70
70
  /**
71
71
  * Where `onData`'s chunks may end. The parser holds an unfinished escape
72
- * sequence open for its rest, for the escape delay, only on a host whose
72
+ * sequence open for its rest — for the escape delay, or a control string
73
+ * for as long as an ask awaits its reply — only on a host whose
73
74
  * chunks can split one; a whole-keys host settles each chunk as it arrives,
74
75
  * because a busy worker there reads a queued next key before any timer
75
76
  * runs (rich-host-input-ab9n).
@@ -95,13 +96,13 @@ export interface TerminalHost {
95
96
  readonly isTTY: boolean;
96
97
  /**
97
98
  * Whether the bytes `write` sends land on a terminal, whatever the input
98
- * is. Colour detection asks this: a program with piped stdin and a terminal
99
+ * is. Color detection asks this: a program with piped stdin and a terminal
99
100
  * stdout still draws on a terminal, and `isTTY` would call it plain.
100
101
  */
101
102
  readonly writesToTerminal: boolean;
102
103
  /**
103
104
  * The environment a program running on this terminal sees — `TERM`,
104
- * `COLORTERM`, `NO_COLOR` and the rest. Colour detection reads it: what a
105
+ * `COLORTERM`, `NO_COLOR` and the rest. Color detection reads it: what a
105
106
  * terminal can draw is a fact about the host, so it travels with the host
106
107
  * rather than being read off whatever `process` the renderer happens to
107
108
  * share a runtime with, which in a browser is none.
@@ -64,7 +64,7 @@ export class BrowserTerminalHost {
64
64
  }
65
65
  // What xterm.js is, stated the way a terminal states it: it identifies as
66
66
  // `xterm-256color` and draws 24-bit SGR. With no env at all, detection saw
67
- // an unnamed TTY and drew every colour at 16-colour depth.
67
+ // an unnamed TTY and drew every color at 16-color depth.
68
68
  get env() {
69
69
  return XTERM_ENV;
70
70
  }