@alchemy.run/sigil 0.0.0-alpha.3 → 0.0.0-alpha.5

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 (207) hide show
  1. package/README.md +23 -13
  2. package/THIRD_PARTY_NOTICES.md +70 -23
  3. package/dist/Text-D5HUf3Fj.d.ts +452 -0
  4. package/dist/ansi.d.ts +137 -143
  5. package/dist/ansi.js +87 -2
  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/{devtools-BhYGjb7h.js → devtools-DbthxoD1.js} +22 -23
  20. package/dist/env-YVw64yZS.js +9 -0
  21. package/dist/escapes-CB_6CWOE.d.ts +72 -0
  22. package/dist/geometry-BxXOzJgo.d.ts +11 -0
  23. package/dist/index-Bmc2tRPk.d.ts +21 -0
  24. package/dist/index.d.ts +235 -754
  25. package/dist/index.js +1362 -3001
  26. package/dist/osc-CCH7xDoS.js +71 -0
  27. package/dist/osc-Cn0fw77g.d.ts +23 -0
  28. package/dist/paint-C19minOS.d.ts +81 -0
  29. package/dist/query-vaIeGOkH.d.ts +152 -0
  30. package/dist/router.d.ts +392 -0
  31. package/dist/router.js +709 -0
  32. package/dist/sample-Cqw1bjUL.js +445 -0
  33. package/dist/screen-BOh__-65.js +324 -0
  34. package/dist/screen-BReKIheE.d.ts +40 -0
  35. package/dist/screen.d.ts +5 -0
  36. package/dist/screen.js +5 -0
  37. package/dist/semantic-text-style-DIMzC7xt.js +91 -0
  38. package/dist/serialize-BTkAZgw1.js +79 -0
  39. package/dist/session-BJmzX2NJ.js +664 -0
  40. package/dist/sgr-BhwaWAJB.js +246 -0
  41. package/dist/store-CgrG9K4y.d.ts +72 -0
  42. package/dist/string-width-CijQwpIk.js +69 -0
  43. package/dist/strip-BvU4toXG.js +6 -0
  44. package/dist/terminal.d.ts +118 -0
  45. package/dist/terminal.js +2 -0
  46. package/dist/tokenize-AjqbvtiT.js +1242 -0
  47. package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
  48. package/dist/truncate-D31fhU6i.js +562 -0
  49. package/dist/use-focus-C2sciZOo.js +1335 -0
  50. package/package.json +43 -17
  51. package/src/ansi/chalk.ts +12 -53
  52. package/src/ansi/cursor.ts +2 -4
  53. package/src/ansi/east-asian-width.ts +44 -0
  54. package/src/ansi/escapes.ts +18 -26
  55. package/src/ansi/graphemes.ts +8 -0
  56. package/src/ansi/hyperlink.ts +44 -0
  57. package/src/ansi/index.ts +14 -14
  58. package/src/ansi/osc.ts +77 -0
  59. package/src/ansi/sgr.ts +2 -5
  60. package/src/ansi/slice.ts +1 -1
  61. package/src/ansi/string-width.ts +91 -206
  62. package/src/ansi/strip.ts +13 -33
  63. package/src/ansi/tokenize.ts +96 -169
  64. package/src/ansi/truncate.ts +5 -3
  65. package/src/ansi/wrap.ts +13 -14
  66. package/src/ansi-tokenizer.ts +1 -1
  67. package/src/capabilities/color-policy.ts +34 -0
  68. package/src/capabilities/detect.ts +594 -0
  69. package/src/capabilities/index.ts +37 -0
  70. package/src/capabilities/query.ts +657 -0
  71. package/src/capabilities/store.ts +379 -0
  72. package/src/color/index.ts +3 -0
  73. package/src/color/paint.ts +169 -0
  74. package/src/color/palette.ts +48 -0
  75. package/src/color/sample.ts +323 -0
  76. package/src/color.ts +1 -0
  77. package/src/components/AnsiText.tsx +42 -0
  78. package/src/components/App.tsx +109 -22
  79. package/src/components/BackgroundContext.ts +2 -3
  80. package/src/components/Box.tsx +43 -60
  81. package/src/components/CursorContext.ts +1 -1
  82. package/src/components/ErrorBoundary.tsx +1 -1
  83. package/src/components/ErrorOverview.tsx +8 -7
  84. package/src/components/Hyperlink.tsx +56 -0
  85. package/src/components/Spacer.tsx +1 -1
  86. package/src/components/Static.tsx +1 -1
  87. package/src/components/StderrContext.ts +0 -2
  88. package/src/components/StdinContext.ts +0 -1
  89. package/src/components/StdoutContext.ts +1 -3
  90. package/src/components/TerminalOscContext.ts +25 -0
  91. package/src/components/Text.tsx +23 -46
  92. package/src/components/Transform.tsx +2 -2
  93. package/src/cursor-position.ts +1 -1
  94. package/src/devtools.ts +84 -24
  95. package/src/dom.ts +11 -8
  96. package/src/env.ts +12 -0
  97. package/src/get-max-width.ts +1 -1
  98. package/src/global.d.ts +6 -4
  99. package/src/{boxes.ts → glyphs.ts} +17 -18
  100. package/src/hooks/use-animation.ts +1 -1
  101. package/src/hooks/use-app.ts +1 -1
  102. package/src/hooks/use-box-metrics.ts +1 -1
  103. package/src/hooks/use-capabilities.ts +73 -0
  104. package/src/hooks/use-cursor.ts +3 -3
  105. package/src/hooks/use-focus-manager.ts +1 -1
  106. package/src/hooks/use-focus.ts +5 -6
  107. package/src/hooks/use-input.ts +4 -4
  108. package/src/hooks/use-is-screen-reader-enabled.ts +1 -1
  109. package/src/hooks/use-paste.ts +2 -2
  110. package/src/hooks/use-stderr.ts +1 -1
  111. package/src/hooks/use-stdin.ts +1 -1
  112. package/src/hooks/use-stdout.ts +1 -1
  113. package/src/hooks/use-terminal-osc.ts +59 -0
  114. package/src/hooks/use-window-size.ts +2 -2
  115. package/src/index.ts +88 -44
  116. package/src/ink.tsx +620 -745
  117. package/src/input-parser.ts +1 -1
  118. package/src/instances.ts +1 -1
  119. package/src/kitty-keyboard.ts +128 -0
  120. package/src/measure-element.ts +1 -1
  121. package/src/measure-text.ts +2 -2
  122. package/src/{render-node-to-output.ts → paint-tree.ts} +78 -53
  123. package/src/parse-keypress.ts +4 -5
  124. package/src/parse-stack-line.ts +0 -1
  125. package/src/patch-console.ts +44 -0
  126. package/src/reconciler.ts +36 -62
  127. package/src/render-background.ts +37 -16
  128. package/src/render-border.ts +97 -64
  129. package/src/render-frame.ts +83 -0
  130. package/src/render-to-string.ts +24 -9
  131. package/src/render.ts +24 -16
  132. package/src/router/components.tsx +343 -0
  133. package/src/router/context.ts +41 -0
  134. package/src/router/history.ts +194 -0
  135. package/src/router/hooks.tsx +391 -0
  136. package/src/router/index.ts +34 -0
  137. package/src/router/matcher.ts +571 -0
  138. package/src/sanitize-ansi.ts +1 -1
  139. package/src/screen/ansi.ts +184 -0
  140. package/src/screen/canvas.ts +137 -0
  141. package/src/screen/cell.ts +138 -0
  142. package/src/screen/color-profile.ts +47 -0
  143. package/src/screen/geometry.ts +9 -0
  144. package/src/screen/index.ts +6 -0
  145. package/src/screen/screen.ts +272 -0
  146. package/src/screen/serialize.ts +129 -0
  147. package/src/screen.ts +1 -0
  148. package/src/semantic-text-style.ts +118 -0
  149. package/src/signal-exit.ts +4 -5
  150. package/src/squash-text-nodes.ts +4 -7
  151. package/src/stream.ts +2 -4
  152. package/src/structured-text.ts +321 -0
  153. package/src/styles.ts +16 -17
  154. package/src/terminal/index.ts +2 -0
  155. package/src/terminal/inline-presenter.ts +120 -0
  156. package/src/terminal/input.ts +86 -0
  157. package/src/terminal/render-scheduler.ts +37 -0
  158. package/src/terminal/screen-presenter.ts +188 -0
  159. package/src/terminal/session.ts +407 -0
  160. package/src/terminal-size.ts +9 -8
  161. package/src/terminal.ts +1 -0
  162. package/src/testing/browser.ts +588 -0
  163. package/src/testing/emulators.ts +205 -0
  164. package/src/testing/explorer-app/index.html +12 -0
  165. package/src/testing/explorer-app/main.ts +381 -0
  166. package/src/testing/explorer-app/style.css +194 -0
  167. package/src/testing/explorer-app/tsconfig.json +15 -0
  168. package/src/testing/explorer-app/vite-env.d.ts +1 -0
  169. package/src/testing/index.ts +26 -0
  170. package/src/testing/keys.ts +56 -0
  171. package/src/testing/live.ts +85 -0
  172. package/src/testing/matchers.ts +70 -0
  173. package/src/testing/public.ts +94 -0
  174. package/src/testing/terminal.ts +349 -0
  175. package/src/testing/vitest.ts +157 -0
  176. package/src/transform-adapter.ts +14 -0
  177. package/src/types.ts +1 -6
  178. package/src/utils.ts +2 -2
  179. package/src/wrap-text.ts +4 -4
  180. package/src/yoga/config.ts +2 -2
  181. package/src/yoga/core/absoluteLayout.ts +15 -15
  182. package/src/yoga/core/baseline.ts +3 -3
  183. package/src/yoga/core/cache.ts +4 -4
  184. package/src/yoga/core/calculateLayout.ts +33 -27
  185. package/src/yoga/core/config.ts +1 -1
  186. package/src/yoga/core/flexLine.ts +3 -3
  187. package/src/yoga/core/helpers.ts +3 -3
  188. package/src/yoga/core/layoutResults.ts +4 -4
  189. package/src/yoga/core/node.ts +16 -16
  190. package/src/yoga/core/pixelGrid.ts +4 -4
  191. package/src/yoga/core/style.ts +11 -11
  192. package/src/yoga/core/types.ts +2 -2
  193. package/src/yoga/index.ts +6 -6
  194. package/src/yoga/node.ts +8 -8
  195. package/dist/sgr-CMfEpjSk.d.ts +0 -91
  196. package/dist/truncate-CBiyyZzw.js +0 -2156
  197. package/src/ansi/supports-color.ts +0 -213
  198. package/src/ansi/widest-line.ts +0 -12
  199. package/src/auto-bind.ts +0 -41
  200. package/src/colorize.ts +0 -60
  201. package/src/devtools-window-polyfill.ts +0 -73
  202. package/src/indent-string.ts +0 -16
  203. package/src/is-in-ci.ts +0 -7
  204. package/src/log-update.ts +0 -370
  205. package/src/output.ts +0 -308
  206. package/src/renderer.ts +0 -73
  207. package/src/write-synchronized.ts +0 -9
@@ -0,0 +1,657 @@
1
+ // Asks the terminal the questions the environment can't answer, using the VT
2
+ // sequences themselves (see https://ghostty.org/docs/vt/reference) rather
3
+ // than environment sniffing: actual colors and theme, mode support via
4
+ // DECRQM, truecolor via XTGETTCAP, graphics protocols, pixel geometry via
5
+ // XTWINOPS, and the terminal's own name/version via XTVERSION.
6
+ //
7
+ // All queries are written in one batch, terminated by Primary Device
8
+ // Attributes (DA1) — every terminal answers DA1, so its response doubles as
9
+ // an "all supported queries have been answered" sentinel (and its parameters
10
+ // report capabilities of their own, like sixel). Unanswered queries are
11
+ // simply absent from the response stream. A timeout covers terminals (or
12
+ // pipes) that never answer at all.
13
+ //
14
+ // Responses are read from a raw `data` listener with the same discipline as
15
+ // `detectKittySupport`: recognized responses are consumed, everything else is
16
+ // unshifted back into the stream so user input survives a query in flight.
17
+ // A `readable` listener on the same stream stops it flowing and would route
18
+ // responses into the input parser as garbage key presses — inside Ink, App
19
+ // detaches its input listener for the duration of the query (see
20
+ // `internal_queryTerminal`); outside Ink, run this before attaching any
21
+ // other stdin consumer.
22
+ import { BEL, CSI, ESC, OSC } from "#/ansi/escapes.ts";
23
+ import {
24
+ type Capabilities,
25
+ type RgbColor,
26
+ type TerminalAppearance,
27
+ } from "#/capabilities/detect.ts";
28
+
29
+ /**
30
+ The DEC private modes queried via DECRQM, by name.
31
+ */
32
+ const queriedModes = {
33
+ focusEvents: 1004,
34
+ sgrMouse: 1006,
35
+ sgrPixelMouse: 1016,
36
+ bracketedPaste: 2004,
37
+ synchronizedOutput: 2026,
38
+ graphemeClustering: 2027,
39
+ colorSchemeUpdates: 2031,
40
+ inBandResize: 2048,
41
+ } as const;
42
+
43
+ type QueriedMode = keyof typeof queriedModes;
44
+
45
+ export type PixelSize = {
46
+ width: number;
47
+ height: number;
48
+ };
49
+
50
+ export type TerminalQueryResult = {
51
+ foreground: RgbColor | undefined;
52
+ background: RgbColor | undefined;
53
+ cursorColor: RgbColor | undefined;
54
+
55
+ /**
56
+ The user's 16-color ANSI palette, when the terminal reports it.
57
+ */
58
+ palette: RgbColor[] | undefined;
59
+
60
+ /**
61
+ The terminal's own appearance, derived from the actual background color's
62
+ luminance — a dark terminal theme on a light OS stays "dark". Falls back
63
+ to the OS color scheme report when the background is unknown.
64
+ */
65
+ appearance: TerminalAppearance | undefined;
66
+
67
+ /**
68
+ The operating system's color preference, from the color scheme report
69
+ (`CSI ? 996 n`). Independent of the terminal's own theme.
70
+ */
71
+ systemAppearance: TerminalAppearance | undefined;
72
+
73
+ /**
74
+ The terminal answered the kitty keyboard protocol query.
75
+ */
76
+ kittyKeyboard: boolean;
77
+
78
+ /**
79
+ The terminal answered the kitty graphics protocol probe.
80
+ */
81
+ kittyGraphics: boolean;
82
+
83
+ /**
84
+ The terminal reports sixel graphics in its DA1 device attributes.
85
+ */
86
+ sixel: boolean;
87
+
88
+ /**
89
+ Raw DA1 device attribute parameters, for capabilities not modeled here.
90
+ */
91
+ deviceAttributes: number[] | undefined;
92
+
93
+ /**
94
+ The terminal confirmed truecolor via XTGETTCAP ("RGB").
95
+ */
96
+ trueColor: boolean;
97
+
98
+ /**
99
+ Focus in/out reporting (mode 1004).
100
+ */
101
+ focusEvents: boolean;
102
+
103
+ /**
104
+ SGR mouse reporting (mode 1006).
105
+ */
106
+ sgrMouse: boolean;
107
+
108
+ /**
109
+ SGR pixel-precision mouse reporting (mode 1016).
110
+ */
111
+ sgrPixelMouse: boolean;
112
+
113
+ /**
114
+ Bracketed paste (mode 2004).
115
+ */
116
+ bracketedPaste: boolean;
117
+
118
+ /**
119
+ Synchronized output (mode 2026).
120
+ */
121
+ synchronizedOutput: boolean;
122
+
123
+ /**
124
+ Grapheme cluster width handling (mode 2027).
125
+ */
126
+ graphemeClustering: boolean;
127
+
128
+ /**
129
+ Push notifications of light/dark scheme changes (mode 2031).
130
+ */
131
+ colorSchemeUpdates: boolean;
132
+
133
+ /**
134
+ In-band window resize notifications (mode 2048).
135
+ */
136
+ inBandResize: boolean;
137
+
138
+ /**
139
+ Text area size in pixels (XTWINOPS 14).
140
+ */
141
+ textAreaPixels: PixelSize | undefined;
142
+
143
+ /**
144
+ Size of a single character cell in pixels (XTWINOPS 16).
145
+ */
146
+ cellPixels: PixelSize | undefined;
147
+
148
+ /**
149
+ The terminal's self-reported name and version (XTVERSION).
150
+ */
151
+ terminal: { raw: string; name: string | undefined; version: string | undefined } | undefined;
152
+ };
153
+
154
+ export type TerminalQueryOptions = {
155
+ /**
156
+ How long to wait for the DA1 sentinel before giving up, in milliseconds.
157
+ */
158
+ timeout?: number;
159
+
160
+ /**
161
+ Also query the 16-color palette (16 extra OSC 4 queries).
162
+
163
+ @default true
164
+ */
165
+ palette?: boolean;
166
+
167
+ /**
168
+ Which queries to send. `"full"` asks everything; `"dynamic"` asks only the
169
+ facts that change over a session — colors/theme and pixel geometry — and
170
+ is what `refreshTerminalQuery` uses. Mode support, protocol support, and
171
+ the terminal's identity are static and never need re-asking.
172
+
173
+ @default "full"
174
+ */
175
+ scope?: "full" | "dynamic";
176
+ };
177
+
178
+ const paletteSize = 16;
179
+
180
+ // 16-bit-per-channel X11 color spec: `rgb:ffff/ffff/ffff` (1-4 hex digits
181
+ // per channel), occasionally a plain `#rrggbb`.
182
+ export const parseColorValue = (value: string): RgbColor | undefined => {
183
+ if (value.startsWith("rgb:")) {
184
+ const parts = value.slice(4).split("/");
185
+ if (parts.length !== 3) {
186
+ return undefined;
187
+ }
188
+
189
+ const channels = parts.map((part) => {
190
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part)) {
191
+ return undefined;
192
+ }
193
+
194
+ return Math.round((Number.parseInt(part, 16) / (16 ** part.length - 1)) * 255);
195
+ });
196
+
197
+ if (channels.some((channel) => channel === undefined)) {
198
+ return undefined;
199
+ }
200
+
201
+ return { r: channels[0]!, g: channels[1]!, b: channels[2]! };
202
+ }
203
+
204
+ if (/^#[0-9a-fA-F]{6}$/.test(value)) {
205
+ return {
206
+ r: Number.parseInt(value.slice(1, 3), 16),
207
+ g: Number.parseInt(value.slice(3, 5), 16),
208
+ b: Number.parseInt(value.slice(5, 7), 16),
209
+ };
210
+ }
211
+
212
+ return undefined;
213
+ };
214
+
215
+ const appearanceOf = (background: RgbColor | undefined): TerminalAppearance | undefined => {
216
+ if (!background) {
217
+ return undefined;
218
+ }
219
+
220
+ const luminance = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
221
+ return luminance > 127.5 ? "light" : "dark";
222
+ };
223
+
224
+ // "kitty(0.31.0)", "tmux 3.4", "WezTerm 20230712-072601-f4abf8fd", …
225
+ const parseXtversion = (raw: string): NonNullable<TerminalQueryResult["terminal"]> => {
226
+ const match = raw.match(/^(.+?)[(\s]v?(\d[\w.-]*)\)?$/);
227
+ if (!match) {
228
+ return { raw, name: raw.toLowerCase() || undefined, version: undefined };
229
+ }
230
+
231
+ return { raw, name: match[1]!.trim().toLowerCase(), version: match[2] };
232
+ };
233
+
234
+ // "RGB" hex-encoded for XTGETTCAP.
235
+ const xtgettcapRgb = "524742";
236
+
237
+ // A 1×1 transparent probe for the kitty graphics protocol; `a=q` asks the
238
+ // terminal to answer (with OK or an error) instead of displaying anything.
239
+ const kittyGraphicsProbeId = 31;
240
+ const kittyGraphicsProbe = `${ESC}_Gi=${kittyGraphicsProbeId},s=1,v=1,a=q,t=d,f=24;AAAA${ESC}\\`;
241
+
242
+ // Response patterns, built from the shared escape constants. OSC replies may
243
+ // terminate with BEL or ST.
244
+ const oscColorResponse = new RegExp(
245
+ `${ESC}\\](\\d+);(?:(\\d+);)?([^${BEL}${ESC}]*)(?:${BEL}|${ESC}\\\\)`,
246
+ );
247
+ const kittyKeyboardResponse = new RegExp(`${ESC}\\[\\?(\\d+)u`);
248
+ const decrqmResponse = new RegExp(`${ESC}\\[\\?(\\d+);(\\d+)\\$y`);
249
+ const xtversionResponse = new RegExp(`${ESC}P>\\|([^${ESC}]*)${ESC}\\\\`);
250
+ const xtgettcapResponse = new RegExp(`${ESC}P([01])\\+r([^${ESC}]*)${ESC}\\\\`);
251
+ const kittyGraphicsResponse = new RegExp(`${ESC}_G([^${ESC}]*)${ESC}\\\\`);
252
+ const winopsResponse = new RegExp(`${ESC}\\[(4|6);(\\d+);(\\d+)t`);
253
+ const colorSchemeResponse = new RegExp(`${ESC}\\[\\?997;(\\d+)n`);
254
+ const da1Response = new RegExp(`${ESC}\\[\\?([\\d;]*)c`);
255
+
256
+ const buildQuery = (palette: boolean, scope: "full" | "dynamic"): string => {
257
+ const queries = [
258
+ `${OSC}10;?${BEL}`, // foreground color
259
+ `${OSC}11;?${BEL}`, // background color
260
+ `${OSC}12;?${BEL}`, // cursor color
261
+ ];
262
+
263
+ if (palette) {
264
+ for (let index = 0; index < paletteSize; index++) {
265
+ queries.push(`${OSC}4;${index};?${BEL}`);
266
+ }
267
+ }
268
+
269
+ if (scope === "full") {
270
+ for (const mode of Object.values(queriedModes)) {
271
+ queries.push(`${CSI}?${mode}$p`); // DECRQM
272
+ }
273
+
274
+ queries.push(
275
+ `${CSI}?u`, // kitty keyboard protocol
276
+ `${ESC}P+q${xtgettcapRgb}${ESC}\\`, // XTGETTCAP "RGB" (truecolor)
277
+ kittyGraphicsProbe,
278
+ `${CSI}>0q`, // XTVERSION
279
+ );
280
+ }
281
+
282
+ queries.push(
283
+ `${CSI}14t`, // text area size in pixels
284
+ `${CSI}16t`, // cell size in pixels
285
+ `${CSI}?996n`, // color scheme (light/dark) report
286
+ `${CSI}c`, // DA1 — the sentinel; answered by every terminal
287
+ );
288
+
289
+ return queries.join("");
290
+ };
291
+
292
+ /**
293
+ Sends a batch of terminal queries and collects the responses. Resolves when
294
+ the terminal answers the DA1 sentinel, or after `timeout` with whatever was
295
+ gathered. Stdin bytes that are not query responses are pushed back into the
296
+ stream.
297
+
298
+ The query is lazy: nothing is sent until something asks. Inside an Ink app,
299
+ use `useCapabilities` — it triggers the query through Ink's input pipeline so
300
+ responses can't collide with key handling. Call this directly only outside of
301
+ Ink, before any other stdin consumer is attached.
302
+ */
303
+ export const queryTerminal = async (
304
+ stdin: NodeJS.ReadableStream,
305
+ stdout: { write: (data: string) => unknown },
306
+ { timeout = 500, palette = true, scope = "full" }: TerminalQueryOptions = {},
307
+ ): Promise<TerminalQueryResult> =>
308
+ new Promise((resolve) => {
309
+ const result: TerminalQueryResult = {
310
+ foreground: undefined,
311
+ background: undefined,
312
+ cursorColor: undefined,
313
+ palette: undefined,
314
+ appearance: undefined,
315
+ systemAppearance: undefined,
316
+ kittyKeyboard: false,
317
+ kittyGraphics: false,
318
+ sixel: false,
319
+ deviceAttributes: undefined,
320
+ trueColor: false,
321
+ focusEvents: false,
322
+ sgrMouse: false,
323
+ sgrPixelMouse: false,
324
+ bracketedPaste: false,
325
+ synchronizedOutput: false,
326
+ graphemeClustering: false,
327
+ colorSchemeUpdates: false,
328
+ inBandResize: false,
329
+ textAreaPixels: undefined,
330
+ cellPixels: undefined,
331
+ terminal: undefined,
332
+ };
333
+ const paletteColors = new Map<number, RgbColor>();
334
+ let reportedAppearance: TerminalAppearance | undefined;
335
+
336
+ let buffer = "";
337
+ // Whether chunks arrive as strings (stream has an encoding set) — the
338
+ // leftover must be unshifted in the same form it was received.
339
+ let receivedStrings = false;
340
+ let done = false;
341
+
342
+ const finish = (): void => {
343
+ if (done) {
344
+ return;
345
+ }
346
+
347
+ done = true;
348
+ clearTimeout(timer);
349
+ stdin.removeListener("data", onData);
350
+
351
+ if (paletteColors.size === paletteSize) {
352
+ result.palette = Array.from({ length: paletteSize }, (_, index) =>
353
+ paletteColors.get(index)!,
354
+ );
355
+ }
356
+
357
+ // The actual background color is the truth about the terminal's own
358
+ // theme; the 997 report reflects the OS preference and only fills in
359
+ // when no background color could be read.
360
+ result.systemAppearance = reportedAppearance;
361
+ result.appearance = appearanceOf(result.background) ?? reportedAppearance;
362
+
363
+ // Re-emit whatever wasn't a query response so it isn't lost.
364
+ if (buffer.length > 0) {
365
+ stdin.unshift(receivedStrings ? buffer : Buffer.from(buffer, "latin1"));
366
+ buffer = "";
367
+ }
368
+
369
+ resolve(result);
370
+ };
371
+
372
+ const consumeMatch = (pattern: RegExp): RegExpMatchArray | undefined => {
373
+ const match = buffer.match(pattern);
374
+ if (match) {
375
+ buffer = buffer.replace(pattern, "");
376
+ }
377
+
378
+ return match ?? undefined;
379
+ };
380
+
381
+ // Consumes the earliest recognized response in the buffer. Returns false
382
+ // when nothing (complete) is left to consume.
383
+ const consumeResponse = (): boolean => {
384
+ const oscMatch = consumeMatch(oscColorResponse);
385
+ if (oscMatch) {
386
+ const [, code, index, value] = oscMatch;
387
+ const color = parseColorValue(value!);
388
+ if (color) {
389
+ if (code === "10") {
390
+ result.foreground = color;
391
+ } else if (code === "11") {
392
+ result.background = color;
393
+ } else if (code === "12") {
394
+ result.cursorColor = color;
395
+ } else if (code === "4" && index !== undefined) {
396
+ paletteColors.set(Number.parseInt(index, 10), color);
397
+ }
398
+ }
399
+
400
+ return true;
401
+ }
402
+
403
+ const xtversionMatch = consumeMatch(xtversionResponse);
404
+ if (xtversionMatch) {
405
+ const raw = xtversionMatch[1]!.trim();
406
+ if (raw.length > 0) {
407
+ result.terminal = parseXtversion(raw);
408
+ }
409
+
410
+ return true;
411
+ }
412
+
413
+ const xtgettcapMatch = consumeMatch(xtgettcapResponse);
414
+ if (xtgettcapMatch) {
415
+ // `DCS 1 + r … ST` is success; the RGB capability existing at all
416
+ // means truecolor.
417
+ if (xtgettcapMatch[1] === "1" && xtgettcapMatch[2]!.includes(xtgettcapRgb)) {
418
+ result.trueColor = true;
419
+ }
420
+
421
+ return true;
422
+ }
423
+
424
+ const graphicsMatch = consumeMatch(kittyGraphicsResponse);
425
+ if (graphicsMatch) {
426
+ if (graphicsMatch[1]!.includes("OK")) {
427
+ result.kittyGraphics = true;
428
+ }
429
+
430
+ return true;
431
+ }
432
+
433
+ const decrqmMatch = consumeMatch(decrqmResponse);
434
+ if (decrqmMatch) {
435
+ const mode = Number.parseInt(decrqmMatch[1]!, 10);
436
+ // 0 = not recognized; 1-4 = recognized (set/reset/permanently so).
437
+ const recognized = decrqmMatch[2] !== "0";
438
+ for (const [name, number] of Object.entries(queriedModes)) {
439
+ if (number === mode) {
440
+ result[name as QueriedMode] = recognized;
441
+ }
442
+ }
443
+
444
+ return true;
445
+ }
446
+
447
+ const winopsMatch = consumeMatch(winopsResponse);
448
+ if (winopsMatch) {
449
+ const size: PixelSize = {
450
+ height: Number.parseInt(winopsMatch[2]!, 10),
451
+ width: Number.parseInt(winopsMatch[3]!, 10),
452
+ };
453
+ if (winopsMatch[1] === "4") {
454
+ result.textAreaPixels = size;
455
+ } else {
456
+ result.cellPixels = size;
457
+ }
458
+
459
+ return true;
460
+ }
461
+
462
+ const colorSchemeMatch = consumeMatch(colorSchemeResponse);
463
+ if (colorSchemeMatch) {
464
+ // 1 = dark, 2 = light.
465
+ if (colorSchemeMatch[1] === "1") {
466
+ reportedAppearance = "dark";
467
+ } else if (colorSchemeMatch[1] === "2") {
468
+ reportedAppearance = "light";
469
+ }
470
+
471
+ return true;
472
+ }
473
+
474
+ const kittyMatch = consumeMatch(kittyKeyboardResponse);
475
+ if (kittyMatch) {
476
+ result.kittyKeyboard = true;
477
+ return true;
478
+ }
479
+
480
+ const da1Match = consumeMatch(da1Response);
481
+ if (da1Match) {
482
+ const attributes = da1Match[1]!
483
+ .split(";")
484
+ .filter((part) => part.length > 0)
485
+ .map((part) => Number.parseInt(part, 10));
486
+ result.deviceAttributes = attributes;
487
+ // The first parameter is the conformance level; the rest are
488
+ // extensions. 4 = sixel graphics.
489
+ result.sixel = attributes.slice(1).includes(4);
490
+ finish();
491
+ return false;
492
+ }
493
+
494
+ return false;
495
+ };
496
+
497
+ const onData = (data: Uint8Array | string): void => {
498
+ receivedStrings = typeof data === "string";
499
+ buffer += typeof data === "string" ? data : Buffer.from(data).toString("latin1");
500
+ while (consumeResponse()) {
501
+ // Keep consuming until only partial/unrelated bytes remain.
502
+ }
503
+ };
504
+
505
+ // Attach before writing so immediate responses aren't missed.
506
+ stdin.on("data", onData);
507
+ const timer = setTimeout(finish, timeout);
508
+
509
+ stdout.write(buildQuery(palette, scope));
510
+ });
511
+
512
+ // One query per terminal is enough — results are cached per stdout stream.
513
+ const queryPromises = new WeakMap<object, Promise<TerminalQueryResult>>();
514
+ const queryResults = new WeakMap<object, TerminalQueryResult>();
515
+
516
+ /**
517
+ Starts (or joins) the terminal query for a stdout stream. The result is
518
+ cached per stream — the terminal is only ever asked once.
519
+ */
520
+ export const ensureTerminalQuery = (
521
+ stdin: NodeJS.ReadableStream,
522
+ stdout: { write: (data: string) => unknown },
523
+ options?: TerminalQueryOptions,
524
+ ): Promise<TerminalQueryResult> => {
525
+ let promise = queryPromises.get(stdout);
526
+ if (!promise) {
527
+ promise = queryTerminal(stdin, stdout, options);
528
+ void promise.then((result) => queryResults.set(stdout, result));
529
+ queryPromises.set(stdout, promise);
530
+ }
531
+
532
+ return promise;
533
+ };
534
+
535
+ /**
536
+ The completed query result for a stdout stream, if the query has finished.
537
+ */
538
+ export const getTerminalQuery = (stdout: object): TerminalQueryResult | undefined =>
539
+ queryResults.get(stdout);
540
+
541
+ export const getTerminalQueryPromise = (stdout: object): Promise<TerminalQueryResult> | undefined =>
542
+ queryPromises.get(stdout);
543
+
544
+ /**
545
+ Merges a partial update (from an unsolicited terminal report) into the
546
+ cached query result. Returns the merged result, or `undefined` when no query
547
+ has completed yet.
548
+ */
549
+ export const patchTerminalQuery = (
550
+ stdout: object,
551
+ patch: Partial<TerminalQueryResult>,
552
+ ): TerminalQueryResult | undefined => {
553
+ const previous = queryResults.get(stdout);
554
+ if (!previous) {
555
+ return undefined;
556
+ }
557
+
558
+ const merged = { ...previous, ...patch };
559
+ queryResults.set(stdout, merged);
560
+ queryPromises.set(stdout, Promise.resolve(merged));
561
+ return merged;
562
+ };
563
+
564
+ const refreshPromises = new WeakMap<object, Promise<TerminalQueryResult>>();
565
+
566
+ /**
567
+ Re-asks the terminal only the dynamic questions — colors/theme and pixel
568
+ geometry, which change when the user switches themes or resizes — and merges
569
+ the answers into the cached result. Static facts (mode and protocol support,
570
+ identity) are kept from the original query. Falls back to a full query when
571
+ none has completed yet; concurrent refreshes share one round-trip.
572
+ */
573
+ export const refreshTerminalQuery = (
574
+ stdin: NodeJS.ReadableStream,
575
+ stdout: { write: (data: string) => unknown },
576
+ options?: TerminalQueryOptions,
577
+ ): Promise<TerminalQueryResult> => {
578
+ const previous = queryResults.get(stdout);
579
+ if (!previous) {
580
+ return ensureTerminalQuery(stdin, stdout, options);
581
+ }
582
+
583
+ const inFlight = refreshPromises.get(stdout);
584
+ if (inFlight) {
585
+ return inFlight;
586
+ }
587
+
588
+ const promise = queryTerminal(stdin, stdout, { ...options, scope: "dynamic" }).then((fresh) => {
589
+ // An unanswered refresh (e.g. timeout) keeps the previous answers.
590
+ const merged: TerminalQueryResult = {
591
+ ...previous,
592
+ foreground: fresh.foreground ?? previous.foreground,
593
+ background: fresh.background ?? previous.background,
594
+ cursorColor: fresh.cursorColor ?? previous.cursorColor,
595
+ palette: fresh.palette ?? previous.palette,
596
+ appearance: fresh.appearance ?? previous.appearance,
597
+ systemAppearance: fresh.systemAppearance ?? previous.systemAppearance,
598
+ textAreaPixels: fresh.textAreaPixels ?? previous.textAreaPixels,
599
+ cellPixels: fresh.cellPixels ?? previous.cellPixels,
600
+ };
601
+ queryResults.set(stdout, merged);
602
+ queryPromises.set(stdout, Promise.resolve(merged));
603
+ refreshPromises.delete(stdout);
604
+ return merged;
605
+ });
606
+ refreshPromises.set(stdout, promise);
607
+ return promise;
608
+ };
609
+
610
+ /**
611
+ Merges an async query result into a synchronous capabilities snapshot,
612
+ producing the complete picture. The query is authoritative where it answered:
613
+ a terminal that confirms truecolor via XTGETTCAP upgrades the sniffed color
614
+ level.
615
+ */
616
+ export const applyTerminalQuery = (
617
+ capabilities: Capabilities,
618
+ query: TerminalQueryResult,
619
+ ): Capabilities => ({
620
+ ...capabilities,
621
+ size: {
622
+ ...capabilities.size,
623
+ pixels:
624
+ query.textAreaPixels || query.cellPixels
625
+ ? { textArea: query.textAreaPixels, cell: query.cellPixels }
626
+ : undefined,
627
+ },
628
+ terminal: {
629
+ ...capabilities.terminal,
630
+ name: query.terminal?.name ?? capabilities.terminal.name,
631
+ version: query.terminal?.version ?? capabilities.terminal.version,
632
+ },
633
+ color: query.trueColor ? { level: 3, depth: 24, trueColor: true } : capabilities.color,
634
+ theme: {
635
+ appearance: query.appearance ?? capabilities.theme.appearance,
636
+ systemAppearance: query.systemAppearance,
637
+ foreground: query.foreground,
638
+ background: query.background,
639
+ cursor: query.cursorColor,
640
+ palette: query.palette,
641
+ },
642
+ supports: {
643
+ ...capabilities.supports,
644
+ color: capabilities.supports.color || query.trueColor,
645
+ kittyKeyboard: query.kittyKeyboard,
646
+ kittyGraphics: query.kittyGraphics,
647
+ sixel: query.sixel,
648
+ focusEvents: query.focusEvents,
649
+ sgrMouse: query.sgrMouse,
650
+ sgrPixelMouse: query.sgrPixelMouse,
651
+ bracketedPaste: query.bracketedPaste,
652
+ synchronizedOutput: query.synchronizedOutput,
653
+ graphemeClustering: query.graphemeClustering,
654
+ colorSchemeUpdates: query.colorSchemeUpdates,
655
+ inBandResize: query.inBandResize,
656
+ },
657
+ });