@alchemy.run/sigil 0.0.0-alpha.1 → 0.0.0-alpha.11

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 (223) hide show
  1. package/README.md +499 -313
  2. package/THIRD_PARTY_NOTICES.md +98 -23
  3. package/dist/Text-DV9CuzAT.d.ts +452 -0
  4. package/dist/ansi.d.ts +217 -0
  5. package/dist/ansi.js +87 -0
  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-BAC9-TZX.js +592 -0
  11. package/dist/color-policy-CCxuHIdD.d.ts +22 -0
  12. package/dist/color-profile-CyeHnG1T.d.ts +97 -0
  13. package/dist/color-profile-DHhQHY55.js +36 -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-B3dL4Q11.js +374 -0
  18. package/dist/detect-Db6GbKOm.d.ts +195 -0
  19. package/dist/{devtools-QpCMm9JH.mjs → devtools-DbthxoD1.js} +23 -24
  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-D48vQhhe.d.ts +21 -0
  24. package/dist/index-DDVME65c.d.ts +919 -0
  25. package/dist/index.d.ts +1310 -0
  26. package/dist/index.js +3211 -0
  27. package/dist/jsx-dev-runtime.d.ts +2 -0
  28. package/dist/jsx-dev-runtime.js +227 -0
  29. package/dist/jsx-runtime-XtwIVFsg.js +255 -0
  30. package/dist/jsx-runtime.d.ts +2 -0
  31. package/dist/jsx-runtime.js +8 -0
  32. package/dist/osc-BFKKSqpg.js +71 -0
  33. package/dist/osc-Cn0fw77g.d.ts +23 -0
  34. package/dist/paint-Cx-zC_sX.d.ts +81 -0
  35. package/dist/query-BNc2B8GD.d.ts +152 -0
  36. package/dist/react-CTZ_8dwh.js +1197 -0
  37. package/dist/react.d.ts +2 -0
  38. package/dist/react.js +49 -0
  39. package/dist/router.d.ts +392 -0
  40. package/dist/router.js +711 -0
  41. package/dist/sample-Cqw1bjUL.js +445 -0
  42. package/dist/screen-CgC2WlVM.d.ts +49 -0
  43. package/dist/screen-CiPytswf.js +342 -0
  44. package/dist/screen.d.ts +5 -0
  45. package/dist/screen.js +5 -0
  46. package/dist/semantic-text-style-DIMzC7xt.js +91 -0
  47. package/dist/serialize-BTkAZgw1.js +79 -0
  48. package/dist/session-DDQ5V300.js +723 -0
  49. package/dist/sgr-BhwaWAJB.js +246 -0
  50. package/dist/store-C1P5fOUi.d.ts +72 -0
  51. package/dist/string-width-CijQwpIk.js +69 -0
  52. package/dist/strip-BvU4toXG.js +6 -0
  53. package/dist/terminal.d.ts +121 -0
  54. package/dist/terminal.js +2 -0
  55. package/dist/tokenize-AjqbvtiT.js +1242 -0
  56. package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
  57. package/dist/truncate-D31fhU6i.js +562 -0
  58. package/dist/use-focus-B5npZrJU.js +18736 -0
  59. package/dist/yoga-5jKhYCJC.js +3465 -0
  60. package/dist/yoga.d.ts +2 -0
  61. package/dist/yoga.js +2 -0
  62. package/package.json +89 -29
  63. package/src/ansi/chalk.ts +138 -0
  64. package/src/ansi/cursor.ts +46 -0
  65. package/src/ansi/east-asian-width.ts +259 -0
  66. package/src/ansi/escapes.ts +120 -0
  67. package/src/ansi/graphemes.ts +8 -0
  68. package/src/ansi/hyperlink.ts +44 -0
  69. package/src/ansi/index.ts +27 -0
  70. package/src/ansi/osc.ts +77 -0
  71. package/src/ansi/sgr.ts +234 -0
  72. package/src/ansi/slice.ts +43 -0
  73. package/src/ansi/string-width.ts +121 -0
  74. package/src/ansi/strip.ts +13 -0
  75. package/src/ansi/tokenize.ts +380 -0
  76. package/src/ansi/truncate.ts +196 -0
  77. package/src/ansi/wrap.ts +765 -0
  78. package/src/ansi-tokenizer.ts +510 -0
  79. package/src/capabilities/color-policy.ts +34 -0
  80. package/src/capabilities/detect.ts +608 -0
  81. package/src/capabilities/index.ts +37 -0
  82. package/src/capabilities/query.ts +679 -0
  83. package/src/capabilities/store.ts +394 -0
  84. package/src/code-excerpt.ts +39 -0
  85. package/src/color/index.ts +3 -0
  86. package/src/color/paint.ts +169 -0
  87. package/src/color/palette.ts +48 -0
  88. package/src/color/sample.ts +323 -0
  89. package/src/color.ts +1 -0
  90. package/src/components/AccessibilityContext.ts +5 -0
  91. package/src/components/AnimationContext.ts +24 -0
  92. package/src/components/AnsiText.tsx +42 -0
  93. package/src/components/App.tsx +878 -0
  94. package/src/components/AppContext.ts +111 -0
  95. package/src/components/BackgroundContext.ts +7 -0
  96. package/src/components/Box.tsx +100 -0
  97. package/src/components/CursorContext.ts +19 -0
  98. package/src/components/ErrorBoundary.tsx +39 -0
  99. package/src/components/ErrorOverview.tsx +135 -0
  100. package/src/components/FocusContext.ts +30 -0
  101. package/src/components/Hyperlink.tsx +56 -0
  102. package/src/components/Newline.tsx +16 -0
  103. package/src/components/Spacer.tsx +11 -0
  104. package/src/components/Static.tsx +60 -0
  105. package/src/components/StderrContext.ts +24 -0
  106. package/src/components/StdinContext.ts +48 -0
  107. package/src/components/StdoutContext.ts +26 -0
  108. package/src/components/TerminalOscContext.ts +25 -0
  109. package/src/components/Text.tsx +122 -0
  110. package/src/components/Transform.tsx +38 -0
  111. package/src/components/VirtualList.tsx +128 -0
  112. package/src/cursor-position.ts +103 -0
  113. package/src/devtools.ts +103 -0
  114. package/src/dom.ts +301 -0
  115. package/src/env.ts +12 -0
  116. package/src/get-max-width.ts +11 -0
  117. package/src/global.d.ts +38 -0
  118. package/src/glyphs.ts +99 -0
  119. package/src/hooks/use-animation.ts +142 -0
  120. package/src/hooks/use-app.ts +8 -0
  121. package/src/hooks/use-box-metrics.ts +134 -0
  122. package/src/hooks/use-capabilities.ts +73 -0
  123. package/src/hooks/use-cursor.ts +33 -0
  124. package/src/hooks/use-focus-manager.ts +62 -0
  125. package/src/hooks/use-focus.ts +82 -0
  126. package/src/hooks/use-input.ts +267 -0
  127. package/src/hooks/use-is-screen-reader-enabled.ts +12 -0
  128. package/src/hooks/use-paste.ts +78 -0
  129. package/src/hooks/use-stderr.ts +8 -0
  130. package/src/hooks/use-stdin.ts +10 -0
  131. package/src/hooks/use-stdout.ts +8 -0
  132. package/src/hooks/use-terminal-osc.ts +59 -0
  133. package/src/hooks/use-virtual-scroll.ts +84 -0
  134. package/src/hooks/use-window-size.ts +37 -0
  135. package/src/index.ts +96 -0
  136. package/src/ink.tsx +1452 -0
  137. package/src/input-parser.ts +303 -0
  138. package/src/instances.ts +9 -0
  139. package/src/jsx-dev-runtime.ts +3 -0
  140. package/src/jsx-runtime.ts +6 -0
  141. package/src/kitty-keyboard.ts +185 -0
  142. package/src/measure-element.ts +62 -0
  143. package/src/measure-text.ts +31 -0
  144. package/src/paint-tree.ts +220 -0
  145. package/src/parse-keypress.ts +515 -0
  146. package/src/parse-stack-line.ts +138 -0
  147. package/src/patch-console.ts +106 -0
  148. package/src/quick-lru.ts +85 -0
  149. package/src/react.ts +53 -0
  150. package/src/reconciler.ts +476 -0
  151. package/src/render-background.ts +59 -0
  152. package/src/render-border.ts +167 -0
  153. package/src/render-frame.ts +83 -0
  154. package/src/render-to-string.ts +146 -0
  155. package/src/render.ts +284 -0
  156. package/src/router/components.tsx +343 -0
  157. package/src/router/context.ts +41 -0
  158. package/src/router/history.ts +194 -0
  159. package/src/router/hooks.tsx +391 -0
  160. package/src/router/index.ts +34 -0
  161. package/src/router/matcher.ts +571 -0
  162. package/src/sanitize-ansi.ts +33 -0
  163. package/src/screen/ansi.ts +184 -0
  164. package/src/screen/canvas.ts +160 -0
  165. package/src/screen/cell.ts +138 -0
  166. package/src/screen/color-profile.ts +47 -0
  167. package/src/screen/geometry.ts +9 -0
  168. package/src/screen/index.ts +6 -0
  169. package/src/screen/screen.ts +305 -0
  170. package/src/screen/serialize.ts +129 -0
  171. package/src/screen.ts +1 -0
  172. package/src/semantic-text-style.ts +118 -0
  173. package/src/signal-exit.ts +106 -0
  174. package/src/squash-text-nodes.ts +37 -0
  175. package/src/stream.ts +28 -0
  176. package/src/structured-text.ts +325 -0
  177. package/src/styles.ts +753 -0
  178. package/src/terminal/index.ts +2 -0
  179. package/src/terminal/inline-presenter.ts +120 -0
  180. package/src/terminal/input.ts +92 -0
  181. package/src/terminal/render-scheduler.ts +37 -0
  182. package/src/terminal/screen-presenter.ts +242 -0
  183. package/src/terminal/session.ts +408 -0
  184. package/src/terminal-size.ts +58 -0
  185. package/src/terminal.ts +1 -0
  186. package/src/testing/browser.ts +588 -0
  187. package/src/testing/emulators.ts +205 -0
  188. package/src/testing/explorer-app/index.html +12 -0
  189. package/src/testing/explorer-app/main.ts +381 -0
  190. package/src/testing/explorer-app/style.css +194 -0
  191. package/src/testing/explorer-app/tsconfig.json +15 -0
  192. package/src/testing/explorer-app/vite-env.d.ts +1 -0
  193. package/src/testing/index.ts +26 -0
  194. package/src/testing/keys.ts +56 -0
  195. package/src/testing/live.ts +85 -0
  196. package/src/testing/matchers.ts +70 -0
  197. package/src/testing/public.ts +94 -0
  198. package/src/testing/terminal.ts +361 -0
  199. package/src/testing/vitest.ts +157 -0
  200. package/src/throttle.ts +73 -0
  201. package/src/transform-adapter.ts +14 -0
  202. package/src/types.ts +10 -0
  203. package/src/virtual-scroll.ts +133 -0
  204. package/src/wrap-text.ts +54 -0
  205. package/src/yoga/config.ts +57 -0
  206. package/src/yoga/core/absoluteLayout.ts +626 -0
  207. package/src/yoga/core/baseline.ts +66 -0
  208. package/src/yoga/core/cache.ts +136 -0
  209. package/src/yoga/core/calculateLayout.ts +2926 -0
  210. package/src/yoga/core/config.ts +104 -0
  211. package/src/yoga/core/flexLine.ts +177 -0
  212. package/src/yoga/core/helpers.ts +293 -0
  213. package/src/yoga/core/layoutResults.ts +167 -0
  214. package/src/yoga/core/node.ts +611 -0
  215. package/src/yoga/core/numeric.ts +44 -0
  216. package/src/yoga/core/pixelGrid.ts +151 -0
  217. package/src/yoga/core/style.ts +887 -0
  218. package/src/yoga/core/types.ts +224 -0
  219. package/src/yoga/generated/YGEnums.ts +263 -0
  220. package/src/yoga/index.ts +19 -0
  221. package/src/yoga/node.ts +1140 -0
  222. package/dist/index.d.mts +0 -2379
  223. package/dist/index.mjs +0 -10072
@@ -0,0 +1,679 @@
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
+ // Also used by the normal input decoder: replies can outlive the query timeout
257
+ // or arrive after its DA1 sentinel. Never deliver those bytes as keystrokes.
258
+ const terminalResponses = [
259
+ oscColorResponse,
260
+ kittyKeyboardResponse,
261
+ decrqmResponse,
262
+ xtversionResponse,
263
+ xtgettcapResponse,
264
+ kittyGraphicsResponse,
265
+ winopsResponse,
266
+ colorSchemeResponse,
267
+ da1Response,
268
+ ].map((pattern) => new RegExp(`^(?:${pattern.source})$`));
269
+
270
+ export const isTerminalQueryResponse = (sequence: string): boolean =>
271
+ terminalResponses.some((pattern) => pattern.test(sequence));
272
+
273
+ const buildQuery = (palette: boolean, scope: "full" | "dynamic"): string => {
274
+ const queries = [
275
+ `${OSC}10;?${BEL}`, // foreground color
276
+ `${OSC}11;?${BEL}`, // background color
277
+ `${OSC}12;?${BEL}`, // cursor color
278
+ ];
279
+
280
+ if (palette) {
281
+ for (let index = 0; index < paletteSize; index++) {
282
+ queries.push(`${OSC}4;${index};?${BEL}`);
283
+ }
284
+ }
285
+
286
+ if (scope === "full") {
287
+ for (const mode of Object.values(queriedModes)) {
288
+ queries.push(`${CSI}?${mode}$p`); // DECRQM
289
+ }
290
+
291
+ queries.push(
292
+ `${CSI}?u`, // kitty keyboard protocol
293
+ `${ESC}P+q${xtgettcapRgb}${ESC}\\`, // XTGETTCAP "RGB" (truecolor)
294
+ kittyGraphicsProbe,
295
+ `${CSI}>0q`, // XTVERSION
296
+ );
297
+ }
298
+
299
+ queries.push(
300
+ `${CSI}14t`, // text area size in pixels
301
+ `${CSI}16t`, // cell size in pixels
302
+ `${CSI}?996n`, // color scheme (light/dark) report
303
+ `${CSI}c`, // DA1 — the sentinel; answered by every terminal
304
+ );
305
+
306
+ return queries.join("");
307
+ };
308
+
309
+ /**
310
+ Sends a batch of terminal queries and collects the responses. Resolves when
311
+ the terminal answers the DA1 sentinel, or after `timeout` with whatever was
312
+ gathered. Stdin bytes that are not query responses are pushed back into the
313
+ stream.
314
+
315
+ The query is lazy: nothing is sent until something asks. Inside an Ink app,
316
+ use `useCapabilities` — it triggers the query through Ink's input pipeline so
317
+ responses can't collide with key handling. Call this directly only outside of
318
+ Ink, before any other stdin consumer is attached.
319
+ */
320
+ export const queryTerminal = async (
321
+ stdin: NodeJS.ReadableStream,
322
+ stdout: { write: (data: string) => unknown },
323
+ { timeout = 500, palette = true, scope = "full" }: TerminalQueryOptions = {},
324
+ ): Promise<TerminalQueryResult> =>
325
+ new Promise((resolve) => {
326
+ const result: TerminalQueryResult = {
327
+ foreground: undefined,
328
+ background: undefined,
329
+ cursorColor: undefined,
330
+ palette: undefined,
331
+ appearance: undefined,
332
+ systemAppearance: undefined,
333
+ kittyKeyboard: false,
334
+ kittyGraphics: false,
335
+ sixel: false,
336
+ deviceAttributes: undefined,
337
+ trueColor: false,
338
+ focusEvents: false,
339
+ sgrMouse: false,
340
+ sgrPixelMouse: false,
341
+ bracketedPaste: false,
342
+ synchronizedOutput: false,
343
+ graphemeClustering: false,
344
+ colorSchemeUpdates: false,
345
+ inBandResize: false,
346
+ textAreaPixels: undefined,
347
+ cellPixels: undefined,
348
+ terminal: undefined,
349
+ };
350
+ const paletteColors = new Map<number, RgbColor>();
351
+ let reportedAppearance: TerminalAppearance | undefined;
352
+
353
+ let buffer = "";
354
+ // Whether chunks arrive as strings (stream has an encoding set) — the
355
+ // leftover must be unshifted in the same form it was received.
356
+ let receivedStrings = false;
357
+ let done = false;
358
+
359
+ const finish = (): void => {
360
+ if (done) {
361
+ return;
362
+ }
363
+
364
+ done = true;
365
+ clearTimeout(timer);
366
+ // Stop flowing before returning buffered bytes. Without this, unshift()
367
+ // can discard a partial reply between removing this data listener and
368
+ // the application's readable listener reattaching (especially on timeout).
369
+ stdin.pause();
370
+ stdin.removeListener("data", onData);
371
+
372
+ if (paletteColors.size === paletteSize) {
373
+ result.palette = Array.from({ length: paletteSize }, (_, index) =>
374
+ paletteColors.get(index)!,
375
+ );
376
+ }
377
+
378
+ // The actual background color is the truth about the terminal's own
379
+ // theme; the 997 report reflects the OS preference and only fills in
380
+ // when no background color could be read.
381
+ result.systemAppearance = reportedAppearance;
382
+ result.appearance = appearanceOf(result.background) ?? reportedAppearance;
383
+
384
+ // Re-emit whatever wasn't a query response so it isn't lost.
385
+ if (buffer.length > 0) {
386
+ stdin.unshift(receivedStrings ? buffer : Buffer.from(buffer, "latin1"));
387
+ buffer = "";
388
+ }
389
+
390
+ resolve(result);
391
+ };
392
+
393
+ const consumeMatch = (pattern: RegExp): RegExpMatchArray | undefined => {
394
+ const match = buffer.match(pattern);
395
+ if (match) {
396
+ buffer = buffer.replace(pattern, "");
397
+ }
398
+
399
+ return match ?? undefined;
400
+ };
401
+
402
+ // Consumes the earliest recognized response in the buffer. Returns false
403
+ // when nothing (complete) is left to consume.
404
+ const consumeResponse = (): boolean => {
405
+ const oscMatch = consumeMatch(oscColorResponse);
406
+ if (oscMatch) {
407
+ const [, code, index, value] = oscMatch;
408
+ const color = parseColorValue(value!);
409
+ if (color) {
410
+ if (code === "10") {
411
+ result.foreground = color;
412
+ } else if (code === "11") {
413
+ result.background = color;
414
+ } else if (code === "12") {
415
+ result.cursorColor = color;
416
+ } else if (code === "4" && index !== undefined) {
417
+ paletteColors.set(Number.parseInt(index, 10), color);
418
+ }
419
+ }
420
+
421
+ return true;
422
+ }
423
+
424
+ const xtversionMatch = consumeMatch(xtversionResponse);
425
+ if (xtversionMatch) {
426
+ const raw = xtversionMatch[1]!.trim();
427
+ if (raw.length > 0) {
428
+ result.terminal = parseXtversion(raw);
429
+ }
430
+
431
+ return true;
432
+ }
433
+
434
+ const xtgettcapMatch = consumeMatch(xtgettcapResponse);
435
+ if (xtgettcapMatch) {
436
+ // `DCS 1 + r … ST` is success; the RGB capability existing at all
437
+ // means truecolor.
438
+ if (xtgettcapMatch[1] === "1" && xtgettcapMatch[2]!.includes(xtgettcapRgb)) {
439
+ result.trueColor = true;
440
+ }
441
+
442
+ return true;
443
+ }
444
+
445
+ const graphicsMatch = consumeMatch(kittyGraphicsResponse);
446
+ if (graphicsMatch) {
447
+ if (graphicsMatch[1]!.includes("OK")) {
448
+ result.kittyGraphics = true;
449
+ }
450
+
451
+ return true;
452
+ }
453
+
454
+ const decrqmMatch = consumeMatch(decrqmResponse);
455
+ if (decrqmMatch) {
456
+ const mode = Number.parseInt(decrqmMatch[1]!, 10);
457
+ // 0 = not recognized; 1-4 = recognized (set/reset/permanently so).
458
+ const recognized = decrqmMatch[2] !== "0";
459
+ for (const [name, number] of Object.entries(queriedModes)) {
460
+ if (number === mode) {
461
+ result[name as QueriedMode] = recognized;
462
+ }
463
+ }
464
+
465
+ return true;
466
+ }
467
+
468
+ const winopsMatch = consumeMatch(winopsResponse);
469
+ if (winopsMatch) {
470
+ const size: PixelSize = {
471
+ height: Number.parseInt(winopsMatch[2]!, 10),
472
+ width: Number.parseInt(winopsMatch[3]!, 10),
473
+ };
474
+ if (winopsMatch[1] === "4") {
475
+ result.textAreaPixels = size;
476
+ } else {
477
+ result.cellPixels = size;
478
+ }
479
+
480
+ return true;
481
+ }
482
+
483
+ const colorSchemeMatch = consumeMatch(colorSchemeResponse);
484
+ if (colorSchemeMatch) {
485
+ // 1 = dark, 2 = light.
486
+ if (colorSchemeMatch[1] === "1") {
487
+ reportedAppearance = "dark";
488
+ } else if (colorSchemeMatch[1] === "2") {
489
+ reportedAppearance = "light";
490
+ }
491
+
492
+ return true;
493
+ }
494
+
495
+ const kittyMatch = consumeMatch(kittyKeyboardResponse);
496
+ if (kittyMatch) {
497
+ result.kittyKeyboard = true;
498
+ return true;
499
+ }
500
+
501
+ const da1Match = consumeMatch(da1Response);
502
+ if (da1Match) {
503
+ const attributes = da1Match[1]!
504
+ .split(";")
505
+ .filter((part) => part.length > 0)
506
+ .map((part) => Number.parseInt(part, 10));
507
+ result.deviceAttributes = attributes;
508
+ // The first parameter is the conformance level; the rest are
509
+ // extensions. 4 = sixel graphics.
510
+ result.sixel = attributes.slice(1).includes(4);
511
+ finish();
512
+ return false;
513
+ }
514
+
515
+ return false;
516
+ };
517
+
518
+ const onData = (data: Uint8Array | string): void => {
519
+ receivedStrings = typeof data === "string";
520
+ buffer += typeof data === "string" ? data : Buffer.from(data).toString("latin1");
521
+ while (consumeResponse()) {
522
+ // Keep consuming until only partial/unrelated bytes remain.
523
+ }
524
+ };
525
+
526
+ // Attach before writing so immediate responses aren't missed.
527
+ stdin.on("data", onData);
528
+ const timer = setTimeout(finish, timeout);
529
+ stdin.resume();
530
+
531
+ stdout.write(buildQuery(palette, scope));
532
+ });
533
+
534
+ // One query per terminal is enough — results are cached per stdout stream.
535
+ const queryPromises = new WeakMap<object, Promise<TerminalQueryResult>>();
536
+ const queryResults = new WeakMap<object, TerminalQueryResult>();
537
+
538
+ /**
539
+ Starts (or joins) the terminal query for a stdout stream. The result is
540
+ cached per stream — the terminal is only ever asked once.
541
+ */
542
+ export const ensureTerminalQuery = (
543
+ stdin: NodeJS.ReadableStream,
544
+ stdout: { write: (data: string) => unknown },
545
+ options?: TerminalQueryOptions,
546
+ ): Promise<TerminalQueryResult> => {
547
+ let promise = queryPromises.get(stdout);
548
+ if (!promise) {
549
+ promise = queryTerminal(stdin, stdout, options);
550
+ void promise.then((result) => queryResults.set(stdout, result));
551
+ queryPromises.set(stdout, promise);
552
+ }
553
+
554
+ return promise;
555
+ };
556
+
557
+ /**
558
+ The completed query result for a stdout stream, if the query has finished.
559
+ */
560
+ export const getTerminalQuery = (stdout: object): TerminalQueryResult | undefined =>
561
+ queryResults.get(stdout);
562
+
563
+ export const getTerminalQueryPromise = (stdout: object): Promise<TerminalQueryResult> | undefined =>
564
+ queryPromises.get(stdout);
565
+
566
+ /**
567
+ Merges a partial update (from an unsolicited terminal report) into the
568
+ cached query result. Returns the merged result, or `undefined` when no query
569
+ has completed yet.
570
+ */
571
+ export const patchTerminalQuery = (
572
+ stdout: object,
573
+ patch: Partial<TerminalQueryResult>,
574
+ ): TerminalQueryResult | undefined => {
575
+ const previous = queryResults.get(stdout);
576
+ if (!previous) {
577
+ return undefined;
578
+ }
579
+
580
+ const merged = { ...previous, ...patch };
581
+ queryResults.set(stdout, merged);
582
+ queryPromises.set(stdout, Promise.resolve(merged));
583
+ return merged;
584
+ };
585
+
586
+ const refreshPromises = new WeakMap<object, Promise<TerminalQueryResult>>();
587
+
588
+ /**
589
+ Re-asks the terminal only the dynamic questions — colors/theme and pixel
590
+ geometry, which change when the user switches themes or resizes — and merges
591
+ the answers into the cached result. Static facts (mode and protocol support,
592
+ identity) are kept from the original query. Falls back to a full query when
593
+ none has completed yet; concurrent refreshes share one round-trip.
594
+ */
595
+ export const refreshTerminalQuery = (
596
+ stdin: NodeJS.ReadableStream,
597
+ stdout: { write: (data: string) => unknown },
598
+ options?: TerminalQueryOptions,
599
+ ): Promise<TerminalQueryResult> => {
600
+ const previous = queryResults.get(stdout);
601
+ if (!previous) {
602
+ return ensureTerminalQuery(stdin, stdout, options);
603
+ }
604
+
605
+ const inFlight = refreshPromises.get(stdout);
606
+ if (inFlight) {
607
+ return inFlight;
608
+ }
609
+
610
+ const promise = queryTerminal(stdin, stdout, { ...options, scope: "dynamic" }).then((fresh) => {
611
+ // An unanswered refresh (e.g. timeout) keeps the previous answers.
612
+ const merged: TerminalQueryResult = {
613
+ ...previous,
614
+ foreground: fresh.foreground ?? previous.foreground,
615
+ background: fresh.background ?? previous.background,
616
+ cursorColor: fresh.cursorColor ?? previous.cursorColor,
617
+ palette: fresh.palette ?? previous.palette,
618
+ appearance: fresh.appearance ?? previous.appearance,
619
+ systemAppearance: fresh.systemAppearance ?? previous.systemAppearance,
620
+ textAreaPixels: fresh.textAreaPixels ?? previous.textAreaPixels,
621
+ cellPixels: fresh.cellPixels ?? previous.cellPixels,
622
+ };
623
+ queryResults.set(stdout, merged);
624
+ queryPromises.set(stdout, Promise.resolve(merged));
625
+ refreshPromises.delete(stdout);
626
+ return merged;
627
+ });
628
+ refreshPromises.set(stdout, promise);
629
+ return promise;
630
+ };
631
+
632
+ /**
633
+ Merges an async query result into a synchronous capabilities snapshot,
634
+ producing the complete picture. The query is authoritative where it answered:
635
+ a terminal that confirms truecolor via XTGETTCAP upgrades the sniffed color
636
+ level.
637
+ */
638
+ export const applyTerminalQuery = (
639
+ capabilities: Capabilities,
640
+ query: TerminalQueryResult,
641
+ ): Capabilities => ({
642
+ ...capabilities,
643
+ size: {
644
+ ...capabilities.size,
645
+ pixels:
646
+ query.textAreaPixels || query.cellPixels
647
+ ? { textArea: query.textAreaPixels, cell: query.cellPixels }
648
+ : undefined,
649
+ },
650
+ terminal: {
651
+ ...capabilities.terminal,
652
+ name: query.terminal?.name ?? capabilities.terminal.name,
653
+ version: query.terminal?.version ?? capabilities.terminal.version,
654
+ },
655
+ color: query.trueColor ? { level: 3, depth: 24, trueColor: true } : capabilities.color,
656
+ theme: {
657
+ appearance: query.appearance ?? capabilities.theme.appearance,
658
+ systemAppearance: query.systemAppearance,
659
+ foreground: query.foreground,
660
+ background: query.background,
661
+ cursor: query.cursorColor,
662
+ palette: query.palette,
663
+ },
664
+ supports: {
665
+ ...capabilities.supports,
666
+ color: capabilities.supports.color || query.trueColor,
667
+ kittyKeyboard: query.kittyKeyboard,
668
+ kittyGraphics: query.kittyGraphics,
669
+ sixel: query.sixel,
670
+ focusEvents: query.focusEvents,
671
+ sgrMouse: query.sgrMouse,
672
+ sgrPixelMouse: query.sgrPixelMouse,
673
+ bracketedPaste: query.bracketedPaste,
674
+ synchronizedOutput: query.synchronizedOutput,
675
+ graphemeClustering: query.graphemeClustering,
676
+ colorSchemeUpdates: query.colorSchemeUpdates,
677
+ inBandResize: query.inBandResize,
678
+ },
679
+ });