@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,323 @@
1
+ import {
2
+ rgb,
3
+ type GradientStop,
4
+ type InterpolationSpace,
5
+ type LinearGradient,
6
+ type Paint,
7
+ type PaintContext,
8
+ type PerimeterGradient,
9
+ } from "#/color/paint.ts";
10
+ import { colorToRgb } from "#/color/palette.ts";
11
+ import type { Color, RgbColor } from "#/screen/cell.ts";
12
+ import type { Rect } from "#/screen/geometry.ts";
13
+ import { parseSemanticColor } from "#/semantic-text-style.ts";
14
+
15
+ export function samplePaint(
16
+ paint: Paint,
17
+ x: number,
18
+ y: number,
19
+ bounds: Rect,
20
+ context: PaintContext = {},
21
+ ): Color | undefined {
22
+ const resolved = resolveConditional(paint, context);
23
+ if (resolved && typeof resolved === "object" && "type" in resolved) {
24
+ if (resolved.type === "perimeter-gradient") {
25
+ return samplePerimeterGradient(resolved, x, y, bounds, context);
26
+ }
27
+ }
28
+ if (isLinearGradient(resolved)) {
29
+ return sampleGradient(resolved, x, y, bounds, context);
30
+ }
31
+ return resolveColor(resolved);
32
+ }
33
+
34
+ export function blend(source: Color, destination: Color, palette?: PaintContext["palette"]): Color {
35
+ const foreground = colorToRgb(source, palette);
36
+ if (foreground.alpha >= 255) return foreground;
37
+ const background = colorToRgb(destination, palette);
38
+ const alpha = foreground.alpha / 255;
39
+ return rgb(
40
+ foreground.red * alpha + background.red * (1 - alpha),
41
+ foreground.green * alpha + background.green * (1 - alpha),
42
+ foreground.blue * alpha + background.blue * (1 - alpha),
43
+ );
44
+ }
45
+
46
+ /** Progressively moves a color toward white in perceptual OKLab space. */
47
+ export function lighten(color: Color, amount: number, palette?: PaintContext["palette"]): RgbColor {
48
+ return interpolateColor(color, rgb(255, 255, 255), amount, "oklab", palette);
49
+ }
50
+
51
+ /** Progressively moves a color toward black in perceptual OKLab space. */
52
+ export function darken(color: Color, amount: number, palette?: PaintContext["palette"]): RgbColor {
53
+ return interpolateColor(color, rgb(0, 0, 0), amount, "oklab", palette);
54
+ }
55
+
56
+ function sampleGradient(
57
+ gradient: LinearGradient,
58
+ x: number,
59
+ y: number,
60
+ bounds: Rect,
61
+ context: PaintContext,
62
+ ): Color | undefined {
63
+ const radians = (gradient.angle * Math.PI) / 180;
64
+ const dx = Math.cos(radians);
65
+ const dy = Math.sin(radians);
66
+ const width = Math.max(1, bounds.width - 1);
67
+ const height = Math.max(1, bounds.height - 1);
68
+ const projections = [0, width * dx, height * dy, width * dx + height * dy];
69
+ const minimum = Math.min(...projections);
70
+ const maximum = Math.max(...projections);
71
+ const projection = (x - bounds.x) * dx + (y - bounds.y) * dy;
72
+ const position = maximum === minimum ? 0 : (projection - minimum) / (maximum - minimum);
73
+ return sampleStops(gradient.stops, position, gradient.space, x, y, bounds, context);
74
+ }
75
+
76
+ function samplePerimeterGradient(
77
+ gradient: PerimeterGradient,
78
+ x: number,
79
+ y: number,
80
+ bounds: Rect,
81
+ context: PaintContext,
82
+ ): Color | undefined {
83
+ const width = Math.max(1, bounds.width);
84
+ const height = Math.max(1, bounds.height);
85
+ const steps = width === 1 || height === 1 ? Math.max(width, height) : 2 * width + 2 * height - 4;
86
+ if (steps <= 1) return samplePaint(gradient.stops[0]!.color, x, y, bounds, context);
87
+
88
+ const left = bounds.x;
89
+ const top = bounds.y;
90
+ const right = left + width - 1;
91
+ const bottom = top + height - 1;
92
+ let index: number;
93
+
94
+ if (height === 1) index = x - left;
95
+ else if (width === 1) index = y - top;
96
+ else if (y <= top) index = Math.max(0, Math.min(width - 1, x - left));
97
+ else if (x >= right) index = width + Math.max(0, Math.min(height - 2, y - top - 1));
98
+ else if (y >= bottom) index = width + height - 2 + Math.max(0, Math.min(width - 1, right - x));
99
+ else index = 2 * width + height - 2 + Math.max(0, Math.min(height - 2, bottom - y - 1));
100
+
101
+ index = modulo(index - gradient.offset, steps);
102
+ const position = blendPosition(index, steps, gradient.stops.length);
103
+ return sampleStops(gradient.stops, position, gradient.space, x, y, bounds, context);
104
+ }
105
+
106
+ /** Matches Lip Gloss Blend1D's even, discrete distribution of stops. */
107
+ function blendPosition(index: number, steps: number, stopCount: number): number {
108
+ if (steps <= stopCount) return index / Math.max(1, stopCount - 1);
109
+ const segments = stopCount - 1;
110
+ const baseSize = Math.floor(steps / segments);
111
+ const remainder = steps % segments;
112
+ let start = 0;
113
+ for (let segment = 0; segment < segments; segment++) {
114
+ const size = baseSize + (segment < remainder ? 1 : 0);
115
+ if (index < start + size) {
116
+ const local = size <= 1 ? 0 : (index - start) / (size - 1);
117
+ return (segment + local) / segments;
118
+ }
119
+ start += size;
120
+ }
121
+ return 1;
122
+ }
123
+
124
+ function sampleStops(
125
+ stops: readonly GradientStop[],
126
+ position: number,
127
+ space: InterpolationSpace,
128
+ x: number,
129
+ y: number,
130
+ bounds: Rect,
131
+ context: PaintContext,
132
+ ): Color | undefined {
133
+ let right = 1;
134
+ while (right < stops.length - 1 && position > stops[right]!.offset!) right++;
135
+ const left = right - 1;
136
+ const start = stops[left]!;
137
+ const end = stops[right]!;
138
+ const span = end.offset! - start.offset!;
139
+ const amount = span === 0 ? 0 : Math.max(0, Math.min(1, (position - start.offset!) / span));
140
+ const startColor = samplePaint(start.color, x, y, bounds, context);
141
+ const endColor = samplePaint(end.color, x, y, bounds, context);
142
+ if (!startColor || !endColor) return startColor ?? endColor;
143
+ return interpolateColor(startColor, endColor, amount, space, context.palette);
144
+ }
145
+
146
+ export function interpolateColor(
147
+ from: Color,
148
+ to: Color,
149
+ amount: number,
150
+ space: InterpolationSpace = "oklab",
151
+ palette?: PaintContext["palette"],
152
+ ): RgbColor {
153
+ const a = colorToRgb(from, palette);
154
+ const b = colorToRgb(to, palette);
155
+ const t = Math.max(0, Math.min(1, amount));
156
+ if (space === "rgb")
157
+ return rgb(
158
+ mix(a.red, b.red, t),
159
+ mix(a.green, b.green, t),
160
+ mix(a.blue, b.blue, t),
161
+ mix(a.alpha, b.alpha, t),
162
+ );
163
+ if (space === "hsv") {
164
+ const ah = toHsv(a),
165
+ bh = toHsv(b);
166
+ let delta = bh[0] - ah[0];
167
+ if (delta > 180) delta -= 360;
168
+ if (delta < -180) delta += 360;
169
+ return fromHsv(
170
+ ah[0] + delta * t,
171
+ mix(ah[1], bh[1], t),
172
+ mix(ah[2], bh[2], t),
173
+ mix(a.alpha, b.alpha, t),
174
+ );
175
+ }
176
+ if (space === "lab") {
177
+ const al = toLab(a),
178
+ bl = toLab(b);
179
+ return fromLab(
180
+ mix(al[0], bl[0], t),
181
+ mix(al[1], bl[1], t),
182
+ mix(al[2], bl[2], t),
183
+ mix(a.alpha, b.alpha, t),
184
+ );
185
+ }
186
+ const al = toOklab(a),
187
+ bl = toOklab(b);
188
+ return fromOklab(
189
+ mix(al[0], bl[0], t),
190
+ mix(al[1], bl[1], t),
191
+ mix(al[2], bl[2], t),
192
+ mix(a.alpha, b.alpha, t),
193
+ );
194
+ }
195
+
196
+ const D65 = [0.95047, 1, 1.08883] as const;
197
+ function labCurve(value: number): number {
198
+ return value > (6 / 29) ** 3 ? Math.cbrt(value) : (value / 3) * (29 / 6) ** 2 + 4 / 29;
199
+ }
200
+ function inverseLabCurve(value: number): number {
201
+ return value > 6 / 29 ? value ** 3 : 3 * (6 / 29) ** 2 * (value - 4 / 29);
202
+ }
203
+ function toLab(color: RgbColor): [number, number, number] {
204
+ const r = linear(color.red),
205
+ g = linear(color.green),
206
+ b = linear(color.blue);
207
+ const x = 0.4123907992659595 * r + 0.357584339383878 * g + 0.1804807884018343 * b;
208
+ const y = 0.2126390058715104 * r + 0.7151686787677559 * g + 0.0721923153607337 * b;
209
+ const z = 0.0193308187155919 * r + 0.119194779794626 * g + 0.9505321522496606 * b;
210
+ const fy = labCurve(y / D65[1]);
211
+ return [1.16 * fy - 0.16, 5 * (labCurve(x / D65[0]) - fy), 2 * (fy - labCurve(z / D65[2]))];
212
+ }
213
+ function fromLab(l: number, a: number, b: number, alpha: number): RgbColor {
214
+ const center = (l + 0.16) / 1.16;
215
+ const x = D65[0] * inverseLabCurve(center + a / 5);
216
+ const y = D65[1] * inverseLabCurve(center);
217
+ const z = D65[2] * inverseLabCurve(center - b / 2);
218
+ return rgb(
219
+ colorfulChannel(3.240969941904521 * x - 1.5373831775700935 * y - 0.4986107602930033 * z),
220
+ colorfulChannel(-0.9692436362808798 * x + 1.8759675015077206 * y + 0.0415550574071756 * z),
221
+ colorfulChannel(0.0556300796969936 * x - 0.2039769588889766 * y + 1.0569715142428786 * z),
222
+ alpha,
223
+ );
224
+ }
225
+
226
+ const mix = (a: number, b: number, t: number) => a + (b - a) * t;
227
+ const linear = (value: number) => {
228
+ const x = value / 255;
229
+ return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
230
+ };
231
+ const gamma = (value: number) =>
232
+ 255 * (value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055);
233
+ const colorfulChannel = (value: number) => {
234
+ const encoded = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
235
+ return Math.floor(Math.round(Math.max(0, Math.min(1, encoded)) * 65_535) / 256);
236
+ };
237
+ function toOklab(c: RgbColor): [number, number, number] {
238
+ const r = linear(c.red),
239
+ g = linear(c.green),
240
+ b = linear(c.blue);
241
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b),
242
+ m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b),
243
+ s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
244
+ return [
245
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
246
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
247
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
248
+ ];
249
+ }
250
+ function fromOklab(L: number, a: number, b: number, alpha: number): RgbColor {
251
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3,
252
+ m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3,
253
+ s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
254
+ return rgb(
255
+ gamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
256
+ gamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
257
+ gamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
258
+ alpha,
259
+ );
260
+ }
261
+ function toHsv(c: RgbColor): [number, number, number] {
262
+ const r = c.red / 255,
263
+ g = c.green / 255,
264
+ b = c.blue / 255,
265
+ max = Math.max(r, g, b),
266
+ min = Math.min(r, g, b),
267
+ d = max - min;
268
+ let h = 0;
269
+ if (d)
270
+ h =
271
+ max === r
272
+ ? 60 * (((g - b) / d) % 6)
273
+ : max === g
274
+ ? 60 * ((b - r) / d + 2)
275
+ : 60 * ((r - g) / d + 4);
276
+ return [(h + 360) % 360, max === 0 ? 0 : d / max, max];
277
+ }
278
+ function fromHsv(h: number, s: number, v: number, alpha: number): RgbColor {
279
+ const c = v * s,
280
+ x = c * (1 - Math.abs(((h / 60) % 2) - 1)),
281
+ m = v - c;
282
+ const [r, g, b] =
283
+ h < 60
284
+ ? [c, x, 0]
285
+ : h < 120
286
+ ? [x, c, 0]
287
+ : h < 180
288
+ ? [0, c, x]
289
+ : h < 240
290
+ ? [0, x, c]
291
+ : h < 300
292
+ ? [x, 0, c]
293
+ : [c, 0, x];
294
+ return rgb((r + m) * 255, (g + m) * 255, (b + m) * 255, alpha);
295
+ }
296
+ function resolveConditional(paint: Paint, context: PaintContext): Paint {
297
+ if (typeof paint !== "object" || paint === null || "model" in paint) return paint;
298
+ if (paint.type === "adaptive-color")
299
+ return resolveConditional(context.appearance === "light" ? paint.light : paint.dark, context);
300
+ if (paint.type === "profile-color")
301
+ return resolveConditional(
302
+ (context.profile && paint.colors[context.profile]) ?? paint.fallback,
303
+ context,
304
+ );
305
+ return paint;
306
+ }
307
+ function resolveColor(value: Paint): Color | undefined {
308
+ return typeof value === "string"
309
+ ? parseSemanticColor(value)
310
+ : typeof value === "object" && value !== null && "model" in value
311
+ ? value
312
+ : undefined;
313
+ }
314
+ function isLinearGradient(value: Paint): value is LinearGradient {
315
+ return (
316
+ typeof value === "object" &&
317
+ value !== null &&
318
+ "type" in value &&
319
+ value.type === "linear-gradient"
320
+ );
321
+ }
322
+
323
+ const modulo = (value: number, divisor: number) => ((value % divisor) + divisor) % divisor;
package/src/color.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "#/color/index.ts";
@@ -0,0 +1,42 @@
1
+ /** @jsxImportSource react */
2
+ import { useContext } from "react";
3
+
4
+ import { stripAnsi } from "#/ansi/strip.ts";
5
+ import { accessibilityContext } from "#/components/AccessibilityContext.ts";
6
+ import { Text } from "#/components/Text.tsx";
7
+ import type { Styles } from "#/styles.ts";
8
+
9
+ export type Props = {
10
+ /** External text containing ANSI SGR styling or OSC 8 hyperlinks. */
11
+ readonly children: string;
12
+ readonly wrap?: Styles["textWrap"];
13
+ readonly "aria-label"?: string;
14
+ readonly "aria-hidden"?: boolean;
15
+ };
16
+
17
+ /**
18
+ * Renders explicitly trusted ANSI-styled output as structured terminal cells.
19
+ * Ordinary `Text` continues to strip terminal control sequences.
20
+ */
21
+ export function AnsiText({
22
+ children,
23
+ wrap = "wrap",
24
+ "aria-label": ariaLabel,
25
+ "aria-hidden": ariaHidden = false,
26
+ }: Props) {
27
+ const { isScreenReaderEnabled } = useContext(accessibilityContext);
28
+
29
+ if (isScreenReaderEnabled) {
30
+ if (ariaHidden) return null;
31
+ return <Text>{ariaLabel ?? stripAnsi(children)}</Text>;
32
+ }
33
+
34
+ return (
35
+ <ink-text
36
+ style={{ flexGrow: 0, flexShrink: 1, flexDirection: "row", textWrap: wrap }}
37
+ internal_ansi
38
+ >
39
+ {children}
40
+ </ink-text>
41
+ );
42
+ }
@@ -13,6 +13,13 @@ import React, {
13
13
 
14
14
  import { cliCursor } from "#/ansi/cursor.ts";
15
15
  import { ansiEscapes, CSI, ESC } from "#/ansi/escapes.ts";
16
+ import {
17
+ ensureTerminalQuery,
18
+ getTerminalQueryPromise,
19
+ refreshTerminalQuery,
20
+ type TerminalQueryResult,
21
+ } from "#/capabilities/query.ts";
22
+ import { registerTerminalIntegration } from "#/capabilities/store.ts";
16
23
  import { animationContext as AnimationContext } from "#/components/AnimationContext.ts";
17
24
  import { AppContext, type SuspendTerminal } from "#/components/AppContext.ts";
18
25
  import { CursorContext } from "#/components/CursorContext.ts";
@@ -21,9 +28,9 @@ import { FocusContext } from "#/components/FocusContext.ts";
21
28
  import { StderrContext } from "#/components/StderrContext.ts";
22
29
  import { StdinContext } from "#/components/StdinContext.ts";
23
30
  import { StdoutContext } from "#/components/StdoutContext.ts";
24
- import { createInputParser } from "#/input-parser.ts";
25
- import { type CursorPosition } from "#/log-update.ts";
31
+ import { type CursorPosition } from "#/cursor-position.ts";
26
32
  import { getRawModeStream, type OutputStream } from "#/stream.ts";
33
+ import type { TerminalInput } from "#/terminal/input.ts";
27
34
 
28
35
  const tab = "\t";
29
36
  const shiftTab = `${CSI}Z`;
@@ -51,6 +58,7 @@ type Props = {
51
58
  readonly setCursorPosition: (position: CursorPosition | undefined) => void;
52
59
  readonly interactive: boolean;
53
60
  readonly renderThrottleMs: number;
61
+ readonly terminalInput: TerminalInput;
54
62
  };
55
63
 
56
64
  type Focusable = {
@@ -76,6 +84,7 @@ export function App({
76
84
  setCursorPosition,
77
85
  interactive,
78
86
  renderThrottleMs,
87
+ terminalInput,
79
88
  }: Props): React.ReactNode {
80
89
  const [isFocusEnabled, setIsFocusEnabled] = useState(true);
81
90
  const [activeFocusId, setActiveFocusId] = useState<string | undefined>(undefined);
@@ -100,7 +109,6 @@ export function App({
100
109
  internal_eventEmitter.current.setMaxListeners(Infinity);
101
110
  // Store the currently attached readable listener to avoid stale closure issues
102
111
  const readableListenerRef = useRef<(() => void) | undefined>(undefined);
103
- const inputParserRef = useRef(createInputParser());
104
112
  const pendingInputFlushRef = useRef<NodeJS.Timeout | undefined>(undefined);
105
113
  // Small delay to let chunked escape sequences complete before flushing as literal input.
106
114
  const pendingInputFlushDelayMilliseconds = 20;
@@ -214,10 +222,10 @@ export function App({
214
222
  }, [stdin]);
215
223
 
216
224
  const clearInputState = useCallback((): void => {
217
- inputParserRef.current.reset();
225
+ terminalInput.reset();
218
226
  clearPendingInputFlush();
219
227
  detachReadableListener();
220
- }, [clearPendingInputFlush, detachReadableListener]);
228
+ }, [clearPendingInputFlush, detachReadableListener, terminalInput]);
221
229
 
222
230
  const disableRawMode = useCallback((): void => {
223
231
  if (!rawModeStdin) {
@@ -274,21 +282,21 @@ export function App({
274
282
  clearPendingInputFlush();
275
283
  pendingInputFlushRef.current = setTimeout(() => {
276
284
  pendingInputFlushRef.current = undefined;
277
- const pendingEscape = inputParserRef.current.flushPendingEscape();
285
+ const pendingEscape = terminalInput.flushPendingEscape();
278
286
  if (!pendingEscape) {
279
287
  return;
280
288
  }
281
289
 
282
290
  emitInput(pendingEscape);
283
291
  }, pendingInputFlushDelayMilliseconds);
284
- }, [clearPendingInputFlush, emitInput]);
292
+ }, [clearPendingInputFlush, emitInput, terminalInput]);
285
293
 
286
294
  const handleReadable = useCallback((): void => {
287
295
  clearPendingInputFlush();
288
296
  let chunk;
289
297
  // eslint-disable-next-line @typescript-eslint/no-restricted-types
290
298
  while ((chunk = stdin.read() as string | null) !== null) {
291
- const inputEvents = inputParserRef.current.push(chunk);
299
+ const inputEvents = terminalInput.push(chunk);
292
300
  for (const event of inputEvents) {
293
301
  if (typeof event === "string") {
294
302
  emitInput(event);
@@ -305,10 +313,10 @@ export function App({
305
313
  }
306
314
  }
307
315
 
308
- if (inputParserRef.current.hasPendingEscape()) {
316
+ if (terminalInput.hasPendingEscape()) {
309
317
  schedulePendingInputFlush();
310
318
  }
311
- }, [stdin, emitInput, clearPendingInputFlush, schedulePendingInputFlush]);
319
+ }, [stdin, emitInput, clearPendingInputFlush, schedulePendingInputFlush, terminalInput]);
312
320
 
313
321
  const attachReadableListener = useCallback((): void => {
314
322
  if (readableListenerRef.current) {
@@ -379,6 +387,86 @@ export function App({
379
387
  [rawModeStdin, stdin, attachReadableListener, clearInputState, disableRawMode],
380
388
  );
381
389
 
390
+ const handleQueryTerminal = useCallback(
391
+ async ({ refresh = false } = {}): Promise<TerminalQueryResult | undefined> => {
392
+ if (!interactive || !isRawModeSupported || !stdout.isTTY) {
393
+ return undefined;
394
+ }
395
+
396
+ // Without a refresh, an existing (or in-flight) query answers directly.
397
+ if (!refresh) {
398
+ const existing = getTerminalQueryPromise(stdout);
399
+ if (existing) {
400
+ return existing;
401
+ }
402
+ }
403
+
404
+ // Own the input stream for the duration of the query: raw mode on so
405
+ // responses arrive unbuffered, and the readable listener detached so they
406
+ // flow to the query's own data listener instead of being parsed as key
407
+ // presses. queryTerminal buffers real user input typed during the query
408
+ // and unshifts it back; reattaching the readable listener delivers it.
409
+ //
410
+ // Note: if kitty keyboard auto-detection is in flight at the same time,
411
+ // both data listeners see the same bytes; that detector answers the same
412
+ // CSI ? u probe this query sends, so it converges to the same result.
413
+ handleSetRawMode(true);
414
+ detachReadableListener();
415
+ try {
416
+ return await (refresh
417
+ ? refreshTerminalQuery(stdin, stdout)
418
+ : ensureTerminalQuery(stdin, stdout));
419
+ } finally {
420
+ attachReadableListener();
421
+ handleSetRawMode(false);
422
+ }
423
+ },
424
+ [
425
+ interactive,
426
+ isRawModeSupported,
427
+ stdin,
428
+ stdout,
429
+ handleSetRawMode,
430
+ detachReadableListener,
431
+ attachReadableListener,
432
+ ],
433
+ );
434
+
435
+ // While the store has push reporting enabled, someone must actually read
436
+ // stdin so the reports reach `ingest` — even in apps without a single
437
+ // `useInput`. Holding raw mode (ref-counted, like `useInput` does) keeps
438
+ // the readable pipeline attached for the duration.
439
+ const reportFeedActiveRef = useRef(false);
440
+ const handleSetReportFeed = useCallback(
441
+ (active: boolean): void => {
442
+ if (active === reportFeedActiveRef.current || !isRawModeSupported) {
443
+ return;
444
+ }
445
+
446
+ reportFeedActiveRef.current = active;
447
+ handleSetRawMode(active);
448
+ },
449
+ [isRawModeSupported, handleSetRawMode],
450
+ );
451
+
452
+ // Hand the capabilities store this instance's integration: queries go
453
+ // through the input pipeline above instead of competing with it, and the
454
+ // report feed keeps that pipeline running while reports are enabled. An
455
+ // insertion effect runs before every passive effect — parent and child —
456
+ // so a child calling store.query() from its own mount effect always finds
457
+ // the integration already registered.
458
+ useInsertionEffect(() => {
459
+ const unregister = registerTerminalIntegration(stdout, {
460
+ runQuery: handleQueryTerminal,
461
+ setReportFeed: handleSetReportFeed,
462
+ });
463
+
464
+ return () => {
465
+ unregister();
466
+ handleSetReportFeed(false);
467
+ };
468
+ }, [stdout, handleQueryTerminal, handleSetReportFeed]);
469
+
382
470
  const handleSetBracketedPasteMode = useCallback(
383
471
  (isEnabled: boolean): void => {
384
472
  if (!stdout.isTTY) {
@@ -1,8 +1,7 @@
1
1
  import { createContext } from "react";
2
2
 
3
- import { type ForegroundColorName } from "#/ansi/sgr.ts";
4
- import { type LiteralUnion } from "#/types.ts";
3
+ import type { Paint } from "#/color/paint.ts";
5
4
 
6
- export type BackgroundColor = LiteralUnion<ForegroundColorName, string>;
5
+ export type BackgroundColor = Paint;
7
6
 
8
7
  export const backgroundContext = createContext<BackgroundColor | undefined>(undefined);
@@ -2,7 +2,6 @@
2
2
  import { useContext, type PropsWithChildren, type Ref } from "react";
3
3
 
4
4
  import { accessibilityContext } from "#/components/AccessibilityContext.ts";
5
- import { backgroundContext } from "#/components/BackgroundContext.ts";
6
5
  import { type DOMElement } from "#/dom.ts";
7
6
  import { type Styles } from "#/styles.ts";
8
7
 
@@ -97,12 +96,5 @@ export function Box({
97
96
  </ink-box>
98
97
  );
99
98
 
100
- // If this Box has a background color, provide it to children via context
101
- if (backgroundColor) {
102
- return (
103
- <backgroundContext.Provider value={backgroundColor}>{boxElement}</backgroundContext.Provider>
104
- );
105
- }
106
-
107
99
  return boxElement;
108
100
  }
@@ -1,6 +1,6 @@
1
1
  import { createContext } from "react";
2
2
 
3
- import { type CursorPosition } from "#/log-update.ts";
3
+ import { type CursorPosition } from "#/cursor-position.ts";
4
4
 
5
5
  export type Props = {
6
6
  /**
@@ -0,0 +1,56 @@
1
+ /** @jsxImportSource react */
2
+ import { type ReactNode } from "react";
3
+
4
+ import { link } from "#/ansi/escapes.ts";
5
+ import { detectHyperlinkSupport } from "#/capabilities/detect.ts";
6
+ import { Text, type Props as TextProps } from "#/components/Text.tsx";
7
+ import { Transform } from "#/components/Transform.tsx";
8
+ import { useStdout } from "#/hooks/use-stdout.ts";
9
+
10
+ export type Props = Omit<TextProps, "children"> & {
11
+ /**
12
+ The URL the hyperlink points to.
13
+ */
14
+ readonly url: string;
15
+
16
+ /**
17
+ When the terminal does not support OSC 8 hyperlinks, append the URL in
18
+ parentheses after the text so it stays reachable. Set to `false` to render
19
+ the text alone.
20
+
21
+ @default true
22
+ */
23
+ readonly fallback?: boolean;
24
+
25
+ readonly children?: ReactNode;
26
+ };
27
+
28
+ /**
29
+ A clickable OSC 8 hyperlink — the counterpart to the router's `<Link>`, which
30
+ navigates between screens. On terminals without hyperlink support it falls
31
+ back to `text (url)`.
32
+
33
+ ```tsx
34
+ <Hyperlink url="https://example.com">Documentation</Hyperlink>
35
+ ```
36
+ */
37
+ export function Hyperlink({ url, fallback = true, children, ...textProps }: Props) {
38
+ const { stdout } = useStdout();
39
+
40
+ if (!detectHyperlinkSupport(stdout)) {
41
+ return (
42
+ <Text {...textProps}>
43
+ {children}
44
+ {fallback ? <Text dimColor> ({url})</Text> : null}
45
+ </Text>
46
+ );
47
+ }
48
+
49
+ // OSC 8 sequences are zero-width, so wrapping after layout is safe. The
50
+ // transform runs per output line, giving every line its own complete pair.
51
+ return (
52
+ <Transform transform={(text) => link(text, url)}>
53
+ <Text {...textProps}>{children}</Text>
54
+ </Transform>
55
+ );
56
+ }
@@ -0,0 +1,25 @@
1
+ import { createContext } from "react";
2
+
3
+ import type { ClipboardSelection, TerminalProgressState } from "#/ansi/osc.ts";
4
+
5
+ export type TerminalOsc = {
6
+ readonly publishProgress: (owner: symbol, state: TerminalProgressState, value?: number) => void;
7
+ readonly copyToClipboard: (text: string, selection?: ClipboardSelection) => void;
8
+ readonly publishTitle: (owner: symbol, title?: string) => void;
9
+ readonly setWorkingDirectory: (directory: URL | string) => void;
10
+ readonly notify: (title: string) => void;
11
+ readonly setPointerShape: (shape: string) => void;
12
+ };
13
+
14
+ const noop = () => {};
15
+
16
+ export const TerminalOscContext = createContext<TerminalOsc>({
17
+ publishProgress: noop,
18
+ copyToClipboard: noop,
19
+ publishTitle: noop,
20
+ setWorkingDirectory: noop,
21
+ notify: noop,
22
+ setPointerShape: noop,
23
+ });
24
+
25
+ TerminalOscContext.displayName = "InternalTerminalOscContext";