@alchemy.run/sigil 0.0.0-alpha.4 → 0.0.0-alpha.6

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 (138) hide show
  1. package/README.md +21 -9
  2. package/THIRD_PARTY_NOTICES.md +39 -1
  3. package/dist/Text-BobFKi74.d.ts +452 -0
  4. package/dist/ansi.d.ts +122 -131
  5. package/dist/ansi.js +86 -6
  6. package/dist/capabilities.d.ts +5 -0
  7. package/dist/capabilities.js +3 -0
  8. package/dist/cell-_ZVhbfl0.js +44 -0
  9. package/dist/color-CkbalRqK.js +2 -0
  10. package/dist/color-policy-BMzMwV7Q.d.ts +22 -0
  11. package/dist/color-policy-SVj1pYTA.js +560 -0
  12. package/dist/color-profile-DHhQHY55.js +36 -0
  13. package/dist/color-profile-u0Nhe9Nv.d.ts +97 -0
  14. package/dist/color.d.ts +21 -0
  15. package/dist/color.js +3 -0
  16. package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
  17. package/dist/detect-Bh4yGP6w.d.ts +186 -0
  18. package/dist/detect-BuTXtY6e.js +373 -0
  19. package/dist/env-YVw64yZS.js +9 -0
  20. package/dist/escapes-CB_6CWOE.d.ts +72 -0
  21. package/dist/geometry-BxXOzJgo.d.ts +11 -0
  22. package/dist/index-DZ88EXJv.d.ts +21 -0
  23. package/dist/index.d.ts +143 -498
  24. package/dist/index.js +1026 -2244
  25. package/dist/osc-CCH7xDoS.js +71 -0
  26. package/dist/osc-Cn0fw77g.d.ts +23 -0
  27. package/dist/paint-C19minOS.d.ts +81 -0
  28. package/dist/query-vaIeGOkH.d.ts +152 -0
  29. package/dist/router.d.ts +392 -0
  30. package/dist/router.js +709 -0
  31. package/dist/sample-Cqw1bjUL.js +445 -0
  32. package/dist/screen-BOSLQ8fF.d.ts +49 -0
  33. package/dist/screen-CiPytswf.js +342 -0
  34. package/dist/screen.d.ts +5 -0
  35. package/dist/screen.js +5 -0
  36. package/dist/semantic-text-style-DIMzC7xt.js +91 -0
  37. package/dist/serialize-BTkAZgw1.js +79 -0
  38. package/dist/session-aZr9O8h3.js +665 -0
  39. package/dist/sgr-BhwaWAJB.js +246 -0
  40. package/dist/store-CgrG9K4y.d.ts +72 -0
  41. package/dist/string-width-CijQwpIk.js +69 -0
  42. package/dist/strip-BvU4toXG.js +6 -0
  43. package/dist/terminal.d.ts +118 -0
  44. package/dist/terminal.js +2 -0
  45. package/dist/tokenize-AjqbvtiT.js +1242 -0
  46. package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
  47. package/dist/truncate-D31fhU6i.js +562 -0
  48. package/dist/use-focus-BzqAJi0n.js +1337 -0
  49. package/package.json +41 -9
  50. package/src/ansi/chalk.ts +5 -3
  51. package/src/ansi/escapes.ts +14 -0
  52. package/src/ansi/graphemes.ts +8 -0
  53. package/src/ansi/hyperlink.ts +44 -0
  54. package/src/ansi/index.ts +3 -1
  55. package/src/ansi/osc.ts +77 -0
  56. package/src/ansi/tokenize.ts +3 -4
  57. package/src/capabilities/color-policy.ts +34 -0
  58. package/src/capabilities/detect.ts +594 -0
  59. package/src/capabilities/index.ts +37 -0
  60. package/src/capabilities/query.ts +657 -0
  61. package/src/capabilities/store.ts +379 -0
  62. package/src/color/index.ts +3 -0
  63. package/src/color/paint.ts +169 -0
  64. package/src/color/palette.ts +48 -0
  65. package/src/color/sample.ts +323 -0
  66. package/src/color.ts +1 -0
  67. package/src/components/AnsiText.tsx +42 -0
  68. package/src/components/App.tsx +98 -10
  69. package/src/components/BackgroundContext.ts +2 -3
  70. package/src/components/Box.tsx +0 -8
  71. package/src/components/CursorContext.ts +1 -1
  72. package/src/components/Hyperlink.tsx +56 -0
  73. package/src/components/TerminalOscContext.ts +25 -0
  74. package/src/components/Text.tsx +22 -45
  75. package/src/components/Transform.tsx +1 -1
  76. package/src/dom.ts +11 -2
  77. package/src/global.d.ts +3 -0
  78. package/src/hooks/use-capabilities.ts +73 -0
  79. package/src/hooks/use-cursor.ts +2 -2
  80. package/src/hooks/use-terminal-osc.ts +59 -0
  81. package/src/index.ts +45 -1
  82. package/src/ink.tsx +213 -153
  83. package/src/{render-node-to-output.ts → paint-tree.ts} +75 -48
  84. package/src/reconciler.ts +24 -3
  85. package/src/render-background.ts +36 -15
  86. package/src/render-border.ts +94 -61
  87. package/src/render-frame.ts +83 -0
  88. package/src/render-to-string.ts +21 -6
  89. package/src/render.ts +15 -6
  90. package/src/router/components.tsx +343 -0
  91. package/src/router/context.ts +41 -0
  92. package/src/router/history.ts +194 -0
  93. package/src/router/hooks.tsx +391 -0
  94. package/src/router/index.ts +34 -0
  95. package/src/router/matcher.ts +571 -0
  96. package/src/screen/ansi.ts +184 -0
  97. package/src/screen/canvas.ts +160 -0
  98. package/src/screen/cell.ts +138 -0
  99. package/src/screen/color-profile.ts +47 -0
  100. package/src/screen/geometry.ts +9 -0
  101. package/src/screen/index.ts +6 -0
  102. package/src/screen/screen.ts +305 -0
  103. package/src/screen/serialize.ts +129 -0
  104. package/src/screen.ts +1 -0
  105. package/src/semantic-text-style.ts +118 -0
  106. package/src/squash-text-nodes.ts +2 -5
  107. package/src/structured-text.ts +325 -0
  108. package/src/styles.ts +19 -14
  109. package/src/terminal/index.ts +2 -0
  110. package/src/terminal/inline-presenter.ts +120 -0
  111. package/src/terminal/input.ts +86 -0
  112. package/src/terminal/render-scheduler.ts +37 -0
  113. package/src/terminal/screen-presenter.ts +188 -0
  114. package/src/terminal/session.ts +407 -0
  115. package/src/terminal.ts +1 -0
  116. package/src/testing/browser.ts +588 -0
  117. package/src/testing/emulators.ts +205 -0
  118. package/src/testing/explorer-app/index.html +12 -0
  119. package/src/testing/explorer-app/main.ts +381 -0
  120. package/src/testing/explorer-app/style.css +194 -0
  121. package/src/testing/explorer-app/tsconfig.json +15 -0
  122. package/src/testing/explorer-app/vite-env.d.ts +1 -0
  123. package/src/testing/index.ts +26 -0
  124. package/src/testing/keys.ts +56 -0
  125. package/src/testing/live.ts +85 -0
  126. package/src/testing/matchers.ts +70 -0
  127. package/src/testing/public.ts +94 -0
  128. package/src/testing/terminal.ts +349 -0
  129. package/src/testing/vitest.ts +157 -0
  130. package/src/transform-adapter.ts +14 -0
  131. package/src/wrap-text.ts +4 -0
  132. package/dist/sgr-CMfEpjSk.d.ts +0 -91
  133. package/dist/truncate-Cr6xVFMa.js +0 -2330
  134. package/src/ansi/supports-color.ts +0 -207
  135. package/src/colorize.ts +0 -60
  136. package/src/log-update.ts +0 -370
  137. package/src/output.ts +0 -308
  138. package/src/renderer.ts +0 -73
@@ -0,0 +1,325 @@
1
+ import { tokenizeAnsi } from "#/ansi-tokenizer.ts";
2
+ import { graphemes } from "#/ansi/graphemes.ts";
3
+ import { stringWidth } from "#/ansi/string-width.ts";
4
+ import { stripAnsi } from "#/ansi/strip.ts";
5
+ import { samplePaint, type PaintContext } from "#/color/index.ts";
6
+ import type { Paint } from "#/color/paint.ts";
7
+ import type { DOMElement, DOMNode } from "#/dom.ts";
8
+ import { cellsFromAnsi } from "#/screen/ansi.ts";
9
+ import { createCell, type Cell, type CellStyle } from "#/screen/cell.ts";
10
+ import type { Rect } from "#/screen/geometry.ts";
11
+ import {
12
+ emptySemanticTextStyle,
13
+ mergeSemanticTextStyles,
14
+ semanticTextStyleToCellStyle,
15
+ type SemanticTextStyle,
16
+ } from "#/semantic-text-style.ts";
17
+ import type { Styles } from "#/styles.ts";
18
+
19
+ type SemanticCell = Cell & {
20
+ readonly foregroundPaint?: Paint;
21
+ readonly backgroundPaint?: Paint;
22
+ };
23
+
24
+ /** Whether a text subtree must retain the ANSI compatibility pipeline. */
25
+ export function hasCompatibilityText(node: DOMElement): boolean {
26
+ if (node.internal_transform && !node.internal_textStyle) {
27
+ return true;
28
+ }
29
+
30
+ return node.childNodes.some((child) => child.nodeName !== "#text" && hasCompatibilityText(child));
31
+ }
32
+
33
+ /** Rasterizes an unwrapped native Text subtree into semantic cell lines. */
34
+ export function structuredTextLines(node: DOMElement): readonly (readonly SemanticCell[])[] {
35
+ const lines: SemanticCell[][] = [[]];
36
+ appendNode(node, emptySemanticTextStyle, lines, false);
37
+ return lines;
38
+ }
39
+
40
+ /** Samples semantic paints only after wrapping and final layout are known. */
41
+ export function sampleStructuredText(
42
+ lines: readonly (readonly SemanticCell[])[],
43
+ bounds: Rect,
44
+ context: PaintContext,
45
+ ): readonly (readonly Cell[])[] {
46
+ return lines.map((line, row) => {
47
+ let column = 0;
48
+ return line.map((cell) => {
49
+ const foreground = cell.foregroundPaint
50
+ ? samplePaint(cell.foregroundPaint, bounds.x + column, bounds.y + row, bounds, context)
51
+ : cell.style.foreground;
52
+ const background = cell.backgroundPaint
53
+ ? samplePaint(cell.backgroundPaint, bounds.x + column, bounds.y + row, bounds, context)
54
+ : cell.style.background;
55
+ column += cell.width;
56
+ return createCell(
57
+ cell.grapheme,
58
+ cell.width,
59
+ {
60
+ ...cell.style,
61
+ ...(foreground ? { foreground } : {}),
62
+ ...(background ? { background } : {}),
63
+ },
64
+ cell.hyperlink,
65
+ cell.reset,
66
+ );
67
+ });
68
+ });
69
+ }
70
+
71
+ export function structuredTextBaseStyle(node: DOMElement): CellStyle {
72
+ return semanticTextStyleToCellStyle(
73
+ mergeSemanticTextStyles(emptySemanticTextStyle, node.internal_textStyle),
74
+ );
75
+ }
76
+
77
+ /** Wraps or truncates semantic cells without converting them through ANSI. */
78
+ export function wrapStructuredText(
79
+ input: readonly (readonly Cell[])[],
80
+ maxWidth: number,
81
+ wrap: Styles["textWrap"],
82
+ baseStyle?: CellStyle,
83
+ ): readonly (readonly Cell[])[] {
84
+ if (wrap === "none") {
85
+ return input.map((line) => [...line]);
86
+ }
87
+
88
+ if (maxWidth < 1) {
89
+ return [[]];
90
+ }
91
+
92
+ return input.flatMap((line) => {
93
+ if (lineWidth(line) <= maxWidth) {
94
+ return [[...line]];
95
+ }
96
+
97
+ if (wrap === "hard") {
98
+ return hardWrap(line, maxWidth);
99
+ }
100
+
101
+ if (wrap === "wrap") {
102
+ return wordWrap(line, maxWidth);
103
+ }
104
+
105
+ return [truncate(line, maxWidth, wrap, baseStyle)];
106
+ });
107
+ }
108
+
109
+ function wordWrap(line: readonly Cell[], maxWidth: number): Cell[][] {
110
+ const words: Cell[][] = [[]];
111
+ const spaces: Cell[] = [];
112
+
113
+ for (const cell of line) {
114
+ if (cell.grapheme === " ") {
115
+ spaces.push(cell);
116
+ words.push([]);
117
+ } else {
118
+ words.at(-1)!.push(cell);
119
+ }
120
+ }
121
+
122
+ const output: Cell[][] = [[]];
123
+ appendHard(output, words[0]!, maxWidth);
124
+
125
+ for (let index = 1; index < words.length; index++) {
126
+ appendHard(output, [spaces[index - 1]!], maxWidth);
127
+ const word = words[index]!;
128
+ const current = output.at(-1)!;
129
+ const wordWidth = lineWidth(word);
130
+ const currentWidth = lineWidth(current);
131
+
132
+ if (wordWidth <= maxWidth && currentWidth + wordWidth > maxWidth) {
133
+ output.push([]);
134
+ } else if (wordWidth > maxWidth && currentWidth > 0) {
135
+ const remaining = maxWidth - currentWidth;
136
+ const breaksHere = 1 + Math.floor((wordWidth - remaining - 1) / maxWidth);
137
+ const breaksNext = Math.floor((wordWidth - 1) / maxWidth);
138
+ if (breaksNext < breaksHere) {
139
+ output.push([]);
140
+ }
141
+ }
142
+
143
+ appendHard(output, word, maxWidth);
144
+ }
145
+
146
+ return output;
147
+ }
148
+
149
+ function hardWrap(line: readonly Cell[], maxWidth: number): Cell[][] {
150
+ const output: Cell[][] = [[]];
151
+ appendHard(output, line, maxWidth);
152
+ return output;
153
+ }
154
+
155
+ function appendHard(output: Cell[][], cells: readonly Cell[], maxWidth: number): void {
156
+ for (const cell of cells) {
157
+ const current = output.at(-1)!;
158
+ if (current.length > 0 && lineWidth(current) + cell.width > maxWidth) {
159
+ output.push([]);
160
+ }
161
+
162
+ output.at(-1)!.push(cell);
163
+ }
164
+ }
165
+
166
+ function truncate(
167
+ line: readonly Cell[],
168
+ maxWidth: number,
169
+ wrap: Styles["textWrap"],
170
+ baseStyle: CellStyle | undefined,
171
+ ): Cell[] {
172
+ if (maxWidth === 1) {
173
+ return [ellipsis(baseStyle ?? cellStyleIntersection(line.at(0), line.at(-1)))];
174
+ }
175
+
176
+ if (wrap === "truncate-start") {
177
+ const right = sliceCells(line, lineWidth(line) - maxWidth + 1, lineWidth(line));
178
+ return [ellipsis(right[0]?.style), ...right];
179
+ }
180
+
181
+ if (wrap === "truncate-middle") {
182
+ const half = Math.min(Math.floor(maxWidth / 2), maxWidth - 1);
183
+ const left = sliceCells(line, 0, half);
184
+ const rightWidth = maxWidth - half - 1;
185
+ const right = sliceCells(line, lineWidth(line) - rightWidth, lineWidth(line));
186
+ return [
187
+ ...left,
188
+ ellipsis(baseStyle ?? cellStyleIntersection(left.at(-1), right.at(0))),
189
+ ...right,
190
+ ];
191
+ }
192
+
193
+ const left = sliceCells(line, 0, maxWidth - 1);
194
+ return [...left, ellipsis(left.at(-1)?.style)];
195
+ }
196
+
197
+ function sliceCells(line: readonly Cell[], start: number, end: number): Cell[] {
198
+ const output: Cell[] = [];
199
+ let column = 0;
200
+
201
+ for (const cell of line) {
202
+ const nextColumn = column + cell.width;
203
+ if (column >= start && nextColumn <= end) {
204
+ output.push(cell);
205
+ }
206
+
207
+ column = nextColumn;
208
+ }
209
+
210
+ return output;
211
+ }
212
+
213
+ function ellipsis(style = semanticTextStyleToCellStyle(emptySemanticTextStyle)): Cell {
214
+ return createCell("…", 1, style);
215
+ }
216
+
217
+ function cellStyleIntersection(left: Cell | undefined, right: Cell | undefined): Cell["style"] {
218
+ if (!left) {
219
+ return right?.style ?? semanticTextStyleToCellStyle(emptySemanticTextStyle);
220
+ }
221
+
222
+ if (!right) {
223
+ return left.style;
224
+ }
225
+
226
+ return {
227
+ ...(sameColor(left.style.foreground, right.style.foreground)
228
+ ? { foreground: left.style.foreground }
229
+ : {}),
230
+ ...(sameColor(left.style.background, right.style.background)
231
+ ? { background: left.style.background }
232
+ : {}),
233
+ ...(sameColor(left.style.underlineColor, right.style.underlineColor)
234
+ ? { underlineColor: left.style.underlineColor }
235
+ : {}),
236
+ underline: left.style.underline === right.style.underline ? left.style.underline : "none",
237
+ // eslint-disable-next-line no-bitwise
238
+ attributes: left.style.attributes & right.style.attributes,
239
+ };
240
+ }
241
+
242
+ function sameColor(left: Cell["style"]["foreground"], right: Cell["style"]["foreground"]): boolean {
243
+ return JSON.stringify(left) === JSON.stringify(right);
244
+ }
245
+
246
+ function lineWidth(line: readonly Cell[]): number {
247
+ return line.reduce((width, cell) => width + cell.width, 0);
248
+ }
249
+
250
+ function appendNode(
251
+ node: DOMNode,
252
+ inherited: SemanticTextStyle,
253
+ lines: SemanticCell[][],
254
+ ansi: boolean,
255
+ ): void {
256
+ if (node.nodeName === "#text") {
257
+ if (ansi) appendAnsiText(node.nodeValue, inherited, lines);
258
+ else appendText(node.nodeValue, inherited, lines);
259
+ return;
260
+ }
261
+
262
+ const style = mergeSemanticTextStyles(inherited, node.internal_textStyle);
263
+ for (const child of node.childNodes) {
264
+ appendNode(child, style, lines, ansi || node.internal_ansi === true);
265
+ }
266
+ }
267
+
268
+ function appendAnsiText(text: string, semantic: SemanticTextStyle, lines: SemanticCell[][]): void {
269
+ const base = semanticTextStyleToCellStyle(semantic);
270
+ for (const cell of cellsFromAnsi(text)) {
271
+ if (cell.grapheme === "\n" || cell.grapheme === "\r\n") {
272
+ lines.push([]);
273
+ continue;
274
+ }
275
+
276
+ const hasForeground = cell.style.foreground !== undefined;
277
+ const hasBackground = cell.style.background !== undefined;
278
+ lines.at(-1)!.push({
279
+ ...cell,
280
+ style: {
281
+ ...((cell.style.foreground ?? base.foreground)
282
+ ? { foreground: cell.style.foreground ?? base.foreground }
283
+ : {}),
284
+ ...((cell.style.background ?? base.background)
285
+ ? { background: cell.style.background ?? base.background }
286
+ : {}),
287
+ ...(cell.style.underlineColor ? { underlineColor: cell.style.underlineColor } : {}),
288
+ underline: cell.style.underline === "none" ? base.underline : cell.style.underline,
289
+ // eslint-disable-next-line no-bitwise
290
+ attributes: base.attributes | cell.style.attributes,
291
+ },
292
+ ...(!hasForeground && semantic.foreground ? { foregroundPaint: semantic.foreground } : {}),
293
+ ...(!hasBackground && semantic.background ? { backgroundPaint: semantic.background } : {}),
294
+ });
295
+ }
296
+ }
297
+
298
+ function appendText(text: string, style: SemanticTextStyle, lines: SemanticCell[][]): void {
299
+ const cellStyle = semanticTextStyleToCellStyle(style);
300
+
301
+ const plainText = tokenizeAnsi(text)
302
+ .filter((token) => token.type === "text")
303
+ .map((token) => token.value)
304
+ .join("");
305
+ for (const segment of graphemes(stripAnsi(plainText))) {
306
+ if (segment === "\n" || segment === "\r\n") {
307
+ lines.push([]);
308
+ continue;
309
+ }
310
+
311
+ lines.at(-1)!.push({
312
+ ...createCell(
313
+ segment,
314
+ Math.max(1, stringWidth(segment)),
315
+ cellStyle,
316
+ undefined,
317
+ style.resetForeground || style.resetBackground
318
+ ? { foreground: style.resetForeground, background: style.resetBackground }
319
+ : undefined,
320
+ ),
321
+ ...(style.foreground ? { foregroundPaint: style.foreground } : {}),
322
+ ...(style.background ? { backgroundPaint: style.background } : {}),
323
+ });
324
+ }
325
+ }
package/src/styles.ts CHANGED
@@ -1,11 +1,15 @@
1
- import { type ForegroundColorName } from "#/ansi/sgr.ts";
1
+ import type { Paint } from "#/color/paint.ts";
2
2
  import { type BoxGlyphs, type BoxStyle } from "#/glyphs.ts";
3
- import { type LiteralUnion } from "#/types.ts";
4
3
  import { Yoga, type Node as YogaNode, type PositionType } from "#/yoga/index.ts";
5
4
 
6
5
  export type Styles = {
7
6
  /*
8
7
  We keep this as a single enum so overflow is one complete choice and invalid combinations like wrap + truncate-middle are unrepresentable. In hindsight, `normal` would have been a clearer default value than `wrap`, since it describes the standard behavior instead of repeating the prop name.
8
+
9
+ `none` neither wraps nor truncates: the text overflows the container (and
10
+ the screen's nominal width). Meant for scrollback content (`<Static>`),
11
+ not live regions, where a physically soft-wrapped line breaks the
12
+ presenter's line accounting.
9
13
  */
10
14
  readonly textWrap?:
11
15
  | "wrap"
@@ -13,7 +17,8 @@ export type Styles = {
13
17
  | "truncate-end"
14
18
  | "truncate"
15
19
  | "truncate-middle"
16
- | "truncate-start";
20
+ | "truncate-start"
21
+ | "none";
17
22
 
18
23
  /**
19
24
  Controls how the element is positioned.
@@ -274,27 +279,27 @@ export type Styles = {
274
279
  /**
275
280
  Change border color. A shorthand for setting `borderTopColor`, `borderRightColor`, `borderBottomColor`, and `borderLeftColor`.
276
281
  */
277
- readonly borderColor?: LiteralUnion<ForegroundColorName, string>;
282
+ readonly borderColor?: Paint;
278
283
 
279
284
  /**
280
285
  Change the top border color. Accepts the same values as `color` in `Text` component.
281
286
  */
282
- readonly borderTopColor?: LiteralUnion<ForegroundColorName, string>;
287
+ readonly borderTopColor?: Paint;
283
288
 
284
289
  /**
285
290
  Change the bottom border color. Accepts the same values as `color` in `Text` component.
286
291
  */
287
- readonly borderBottomColor?: LiteralUnion<ForegroundColorName, string>;
292
+ readonly borderBottomColor?: Paint;
288
293
 
289
294
  /**
290
295
  Change the left border color. Accepts the same values as `color` in `Text` component.
291
296
  */
292
- readonly borderLeftColor?: LiteralUnion<ForegroundColorName, string>;
297
+ readonly borderLeftColor?: Paint;
293
298
 
294
299
  /**
295
300
  Change the right border color. Accepts the same values as `color` in `Text` component.
296
301
  */
297
- readonly borderRightColor?: LiteralUnion<ForegroundColorName, string>;
302
+ readonly borderRightColor?: Paint;
298
303
 
299
304
  /**
300
305
  Dim the border color. A shorthand for setting `borderTopDimColor`, `borderBottomDimColor`, `borderLeftDimColor`, and `borderRightDimColor`.
@@ -334,27 +339,27 @@ export type Styles = {
334
339
  /**
335
340
  Change border background color. A shorthand for setting `borderTopBackgroundColor`, `borderRightBackgroundColor`, `borderBottomBackgroundColor`, and `borderLeftBackgroundColor`.
336
341
  */
337
- readonly borderBackgroundColor?: LiteralUnion<ForegroundColorName, string>;
342
+ readonly borderBackgroundColor?: Paint;
338
343
 
339
344
  /**
340
345
  Change top border background color. Accepts the same values as `backgroundColor` in `Text` component.
341
346
  */
342
- readonly borderTopBackgroundColor?: LiteralUnion<ForegroundColorName, string>;
347
+ readonly borderTopBackgroundColor?: Paint;
343
348
 
344
349
  /**
345
350
  Change bottom border background color. Accepts the same values as `backgroundColor` in `Text` component.
346
351
  */
347
- readonly borderBottomBackgroundColor?: LiteralUnion<ForegroundColorName, string>;
352
+ readonly borderBottomBackgroundColor?: Paint;
348
353
 
349
354
  /**
350
355
  Change left border background color. Accepts the same values as `backgroundColor` in `Text` component.
351
356
  */
352
- readonly borderLeftBackgroundColor?: LiteralUnion<ForegroundColorName, string>;
357
+ readonly borderLeftBackgroundColor?: Paint;
353
358
 
354
359
  /**
355
360
  Change right border background color. Accepts the same values as `backgroundColor` in `Text` component.
356
361
  */
357
- readonly borderRightBackgroundColor?: LiteralUnion<ForegroundColorName, string>;
362
+ readonly borderRightBackgroundColor?: Paint;
358
363
 
359
364
  /**
360
365
  Behavior for an element's overflow in both directions.
@@ -382,7 +387,7 @@ export type Styles = {
382
387
 
383
388
  Accepts the same values as `color` in the `<Text>` component.
384
389
  */
385
- readonly backgroundColor?: LiteralUnion<ForegroundColorName, string>;
390
+ readonly backgroundColor?: Paint;
386
391
  };
387
392
 
388
393
  const positionEdges = [
@@ -0,0 +1,2 @@
1
+ export * from "#/terminal/input.ts";
2
+ export * from "#/terminal/session.ts";
@@ -0,0 +1,120 @@
1
+ import { cliCursor } from "#/ansi/cursor.ts";
2
+ import { ansiEscapes } from "#/ansi/escapes.ts";
3
+ import {
4
+ buildCursorOnlySequence,
5
+ buildCursorSuffix,
6
+ buildReturnToBottomPrefix,
7
+ cursorPositionChanged,
8
+ hideCursorEscape,
9
+ type CursorPosition,
10
+ } from "#/cursor-position.ts";
11
+
12
+ export type InlinePresenter = {
13
+ clear: () => void;
14
+ done: () => void;
15
+ reset: () => void;
16
+ sync: (output: string) => void;
17
+ setCursorPosition: (position: CursorPosition | undefined) => void;
18
+ isCursorDirty: () => boolean;
19
+ willRender: (output: string) => boolean;
20
+ (output: string): boolean;
21
+ };
22
+
23
+ /** Full-rewrite fallback and cursor state for the structured inline renderer. */
24
+ export function createInlinePresenter(
25
+ stream: NodeJS.WritableStream,
26
+ options: { readonly showCursor?: boolean } = {},
27
+ ): InlinePresenter {
28
+ let previousLineCount = 0;
29
+ let previousOutput = "";
30
+ let hasHiddenCursor = false;
31
+ let cursorPosition: CursorPosition | undefined;
32
+ let cursorDirty = false;
33
+ let previousCursorPosition: CursorPosition | undefined;
34
+ let cursorWasShown = false;
35
+ const showCursor = options.showCursor ?? false;
36
+
37
+ const activeCursor = () => (cursorDirty ? cursorPosition : undefined);
38
+ const hasChanges = (output: string, cursor: CursorPosition | undefined) =>
39
+ output !== previousOutput || cursorPositionChanged(cursor, previousCursorPosition);
40
+
41
+ const present = (output: string): boolean => {
42
+ if (!showCursor && !hasHiddenCursor) {
43
+ cliCursor.hide(stream);
44
+ hasHiddenCursor = true;
45
+ }
46
+ const cursor = activeCursor();
47
+ cursorDirty = false;
48
+ const cursorChanged = cursorPositionChanged(cursor, previousCursorPosition);
49
+ if (!hasChanges(output, cursor)) return false;
50
+ const lines = output.split("\n");
51
+ const suffix = buildCursorSuffix(lines.length - 1, cursor);
52
+ if (output === previousOutput && cursorChanged) {
53
+ stream.write(
54
+ buildCursorOnlySequence({
55
+ cursorWasShown,
56
+ previousLineCount,
57
+ previousCursorPosition,
58
+ cursorPosition: cursor,
59
+ }),
60
+ );
61
+ } else {
62
+ previousOutput = output;
63
+ stream.write(
64
+ buildReturnToBottomPrefix(cursorWasShown, previousLineCount, previousCursorPosition) +
65
+ ansiEscapes.eraseLines(previousLineCount) +
66
+ output +
67
+ suffix,
68
+ );
69
+ previousLineCount = lines.length;
70
+ }
71
+ previousCursorPosition = cursor ? { ...cursor } : undefined;
72
+ cursorWasShown = cursor !== undefined;
73
+ return true;
74
+ };
75
+
76
+ present.clear = () => {
77
+ stream.write(
78
+ buildReturnToBottomPrefix(cursorWasShown, previousLineCount, previousCursorPosition) +
79
+ ansiEscapes.eraseLines(previousLineCount),
80
+ );
81
+ previousOutput = "";
82
+ previousLineCount = 0;
83
+ previousCursorPosition = undefined;
84
+ cursorWasShown = false;
85
+ };
86
+ present.done = () => {
87
+ previousOutput = "";
88
+ previousLineCount = 0;
89
+ previousCursorPosition = undefined;
90
+ cursorWasShown = false;
91
+ if (!showCursor) {
92
+ cliCursor.show(stream);
93
+ hasHiddenCursor = false;
94
+ }
95
+ };
96
+ present.reset = () => {
97
+ previousOutput = "";
98
+ previousLineCount = 0;
99
+ previousCursorPosition = undefined;
100
+ cursorWasShown = false;
101
+ };
102
+ present.sync = (output: string) => {
103
+ const cursor = activeCursor();
104
+ cursorDirty = false;
105
+ const lines = output.split("\n");
106
+ previousOutput = output;
107
+ previousLineCount = lines.length;
108
+ if (!cursor && cursorWasShown) stream.write(hideCursorEscape);
109
+ if (cursor) stream.write(buildCursorSuffix(lines.length - 1, cursor));
110
+ previousCursorPosition = cursor ? { ...cursor } : undefined;
111
+ cursorWasShown = cursor !== undefined;
112
+ };
113
+ present.setCursorPosition = (position: CursorPosition | undefined) => {
114
+ cursorPosition = position;
115
+ cursorDirty = true;
116
+ };
117
+ present.isCursorDirty = () => cursorDirty;
118
+ present.willRender = (output: string) => hasChanges(output, activeCursor());
119
+ return present;
120
+ }
@@ -0,0 +1,86 @@
1
+ import type { CapabilitiesStore } from "#/capabilities/store.ts";
2
+ import { createInputParser, type InputEvent } from "#/input-parser.ts";
3
+
4
+ export type MouseButton = "left" | "middle" | "right" | "none" | "wheel-up" | "wheel-down";
5
+ export type TerminalMouseEvent = {
6
+ readonly type: "press" | "release" | "move" | "wheel";
7
+ readonly x: number;
8
+ readonly y: number;
9
+ readonly button: MouseButton;
10
+ readonly shift: boolean;
11
+ readonly alt: boolean;
12
+ readonly ctrl: boolean;
13
+ };
14
+
15
+ const sgrMouse = /^\u001B\[<(\d+);(\d+);(\d+)([Mm])$/;
16
+
17
+ /** Stateful stdin decoder that separates terminal reports from application input. */
18
+ export class TerminalInput {
19
+ readonly #parser = createInputParser();
20
+ readonly #capabilities: CapabilitiesStore;
21
+ readonly #mouseListeners = new Set<(event: TerminalMouseEvent) => void>();
22
+
23
+ constructor(capabilities: CapabilitiesStore) {
24
+ this.#capabilities = capabilities;
25
+ }
26
+
27
+ push(chunk: string): InputEvent[] {
28
+ return this.#parser.push(chunk).filter((event) => {
29
+ if (typeof event !== "string") return true;
30
+ if (this.#capabilities.ingest(event)) return false;
31
+ const mouse = parseMouseEvent(event);
32
+ if (!mouse) return true;
33
+ for (const listener of this.#mouseListeners) listener(mouse);
34
+ return false;
35
+ });
36
+ }
37
+
38
+ subscribeMouse(listener: (event: TerminalMouseEvent) => void): () => void {
39
+ this.#mouseListeners.add(listener);
40
+ return () => this.#mouseListeners.delete(listener);
41
+ }
42
+
43
+ hasPendingEscape(): boolean {
44
+ return this.#parser.hasPendingEscape();
45
+ }
46
+
47
+ flushPendingEscape(): string | undefined {
48
+ return this.#parser.flushPendingEscape();
49
+ }
50
+
51
+ reset(): void {
52
+ this.#parser.reset();
53
+ }
54
+ }
55
+
56
+ export function parseMouseEvent(sequence: string): TerminalMouseEvent | undefined {
57
+ const match = sequence.match(sgrMouse);
58
+ if (!match) return;
59
+ const code = Number(match[1]);
60
+ const column = Number(match[2]);
61
+ const row = Number(match[3]);
62
+ if (column < 1 || row < 1) return;
63
+ const wheel = (code & 64) !== 0;
64
+ const move = (code & 32) !== 0;
65
+ const buttonCode = code & 3;
66
+ const button: MouseButton = wheel
67
+ ? buttonCode === 0
68
+ ? "wheel-up"
69
+ : "wheel-down"
70
+ : (["left", "middle", "right", "none"] as const)[buttonCode]!;
71
+ return {
72
+ type: wheel
73
+ ? "wheel"
74
+ : move
75
+ ? "move"
76
+ : match[4] === "m" || buttonCode === 3
77
+ ? "release"
78
+ : "press",
79
+ x: column - 1,
80
+ y: row - 1,
81
+ button,
82
+ shift: (code & 4) !== 0,
83
+ alt: (code & 8) !== 0,
84
+ ctrl: (code & 16) !== 0,
85
+ };
86
+ }
@@ -0,0 +1,37 @@
1
+ import { throttle, type Throttled } from "#/throttle.ts";
2
+
3
+ export type RenderScheduler = {
4
+ readonly intervalMs: number;
5
+ readonly throttled: Throttled<never[]> | undefined;
6
+ readonly pending: boolean;
7
+ readonly schedule: () => void;
8
+ readonly immediate: () => void;
9
+ markRendered: () => void;
10
+ };
11
+
12
+ /** Owns frame cadence independently of React reconciliation and terminal presentation. */
13
+ export function createRenderScheduler(
14
+ render: () => void,
15
+ options: { readonly unthrottled: boolean; readonly maxFps: number },
16
+ ): RenderScheduler {
17
+ const frameInterval = options.maxFps > 0 ? Math.max(1, Math.ceil(1000 / options.maxFps)) : 0;
18
+ let pending = false;
19
+ const throttled = options.unthrottled ? undefined : throttle(render, frameInterval);
20
+ return {
21
+ intervalMs: options.unthrottled ? 0 : frameInterval,
22
+ throttled,
23
+ get pending() {
24
+ return pending;
25
+ },
26
+ schedule: options.unthrottled
27
+ ? render
28
+ : () => {
29
+ pending = true;
30
+ throttled!();
31
+ },
32
+ immediate: render,
33
+ markRendered() {
34
+ pending = false;
35
+ },
36
+ };
37
+ }