@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.
- package/README.md +21 -9
- package/THIRD_PARTY_NOTICES.md +39 -1
- package/dist/Text-BobFKi74.d.ts +452 -0
- package/dist/ansi.d.ts +122 -131
- package/dist/ansi.js +86 -6
- package/dist/capabilities.d.ts +5 -0
- package/dist/capabilities.js +3 -0
- package/dist/cell-_ZVhbfl0.js +44 -0
- package/dist/color-CkbalRqK.js +2 -0
- package/dist/color-policy-BMzMwV7Q.d.ts +22 -0
- package/dist/color-policy-SVj1pYTA.js +560 -0
- package/dist/color-profile-DHhQHY55.js +36 -0
- package/dist/color-profile-u0Nhe9Nv.d.ts +97 -0
- package/dist/color.d.ts +21 -0
- package/dist/color.js +3 -0
- package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
- package/dist/detect-Bh4yGP6w.d.ts +186 -0
- package/dist/detect-BuTXtY6e.js +373 -0
- package/dist/env-YVw64yZS.js +9 -0
- package/dist/escapes-CB_6CWOE.d.ts +72 -0
- package/dist/geometry-BxXOzJgo.d.ts +11 -0
- package/dist/index-DZ88EXJv.d.ts +21 -0
- package/dist/index.d.ts +143 -498
- package/dist/index.js +1026 -2244
- package/dist/osc-CCH7xDoS.js +71 -0
- package/dist/osc-Cn0fw77g.d.ts +23 -0
- package/dist/paint-C19minOS.d.ts +81 -0
- package/dist/query-vaIeGOkH.d.ts +152 -0
- package/dist/router.d.ts +392 -0
- package/dist/router.js +709 -0
- package/dist/sample-Cqw1bjUL.js +445 -0
- package/dist/screen-BOSLQ8fF.d.ts +49 -0
- package/dist/screen-CiPytswf.js +342 -0
- package/dist/screen.d.ts +5 -0
- package/dist/screen.js +5 -0
- package/dist/semantic-text-style-DIMzC7xt.js +91 -0
- package/dist/serialize-BTkAZgw1.js +79 -0
- package/dist/session-aZr9O8h3.js +665 -0
- package/dist/sgr-BhwaWAJB.js +246 -0
- package/dist/store-CgrG9K4y.d.ts +72 -0
- package/dist/string-width-CijQwpIk.js +69 -0
- package/dist/strip-BvU4toXG.js +6 -0
- package/dist/terminal.d.ts +118 -0
- package/dist/terminal.js +2 -0
- package/dist/tokenize-AjqbvtiT.js +1242 -0
- package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
- package/dist/truncate-D31fhU6i.js +562 -0
- package/dist/use-focus-BzqAJi0n.js +1337 -0
- package/package.json +41 -9
- package/src/ansi/chalk.ts +5 -3
- package/src/ansi/escapes.ts +14 -0
- package/src/ansi/graphemes.ts +8 -0
- package/src/ansi/hyperlink.ts +44 -0
- package/src/ansi/index.ts +3 -1
- package/src/ansi/osc.ts +77 -0
- package/src/ansi/tokenize.ts +3 -4
- package/src/capabilities/color-policy.ts +34 -0
- package/src/capabilities/detect.ts +594 -0
- package/src/capabilities/index.ts +37 -0
- package/src/capabilities/query.ts +657 -0
- package/src/capabilities/store.ts +379 -0
- package/src/color/index.ts +3 -0
- package/src/color/paint.ts +169 -0
- package/src/color/palette.ts +48 -0
- package/src/color/sample.ts +323 -0
- package/src/color.ts +1 -0
- package/src/components/AnsiText.tsx +42 -0
- package/src/components/App.tsx +98 -10
- package/src/components/BackgroundContext.ts +2 -3
- package/src/components/Box.tsx +0 -8
- package/src/components/CursorContext.ts +1 -1
- package/src/components/Hyperlink.tsx +56 -0
- package/src/components/TerminalOscContext.ts +25 -0
- package/src/components/Text.tsx +22 -45
- package/src/components/Transform.tsx +1 -1
- package/src/dom.ts +11 -2
- package/src/global.d.ts +3 -0
- package/src/hooks/use-capabilities.ts +73 -0
- package/src/hooks/use-cursor.ts +2 -2
- package/src/hooks/use-terminal-osc.ts +59 -0
- package/src/index.ts +45 -1
- package/src/ink.tsx +213 -153
- package/src/{render-node-to-output.ts → paint-tree.ts} +75 -48
- package/src/reconciler.ts +24 -3
- package/src/render-background.ts +36 -15
- package/src/render-border.ts +94 -61
- package/src/render-frame.ts +83 -0
- package/src/render-to-string.ts +21 -6
- package/src/render.ts +15 -6
- package/src/router/components.tsx +343 -0
- package/src/router/context.ts +41 -0
- package/src/router/history.ts +194 -0
- package/src/router/hooks.tsx +391 -0
- package/src/router/index.ts +34 -0
- package/src/router/matcher.ts +571 -0
- package/src/screen/ansi.ts +184 -0
- package/src/screen/canvas.ts +160 -0
- package/src/screen/cell.ts +138 -0
- package/src/screen/color-profile.ts +47 -0
- package/src/screen/geometry.ts +9 -0
- package/src/screen/index.ts +6 -0
- package/src/screen/screen.ts +305 -0
- package/src/screen/serialize.ts +129 -0
- package/src/screen.ts +1 -0
- package/src/semantic-text-style.ts +118 -0
- package/src/squash-text-nodes.ts +2 -5
- package/src/structured-text.ts +325 -0
- package/src/styles.ts +19 -14
- package/src/terminal/index.ts +2 -0
- package/src/terminal/inline-presenter.ts +120 -0
- package/src/terminal/input.ts +86 -0
- package/src/terminal/render-scheduler.ts +37 -0
- package/src/terminal/screen-presenter.ts +188 -0
- package/src/terminal/session.ts +407 -0
- package/src/terminal.ts +1 -0
- package/src/testing/browser.ts +588 -0
- package/src/testing/emulators.ts +205 -0
- package/src/testing/explorer-app/index.html +12 -0
- package/src/testing/explorer-app/main.ts +381 -0
- package/src/testing/explorer-app/style.css +194 -0
- package/src/testing/explorer-app/tsconfig.json +15 -0
- package/src/testing/explorer-app/vite-env.d.ts +1 -0
- package/src/testing/index.ts +26 -0
- package/src/testing/keys.ts +56 -0
- package/src/testing/live.ts +85 -0
- package/src/testing/matchers.ts +70 -0
- package/src/testing/public.ts +94 -0
- package/src/testing/terminal.ts +349 -0
- package/src/testing/vitest.ts +157 -0
- package/src/transform-adapter.ts +14 -0
- package/src/wrap-text.ts +4 -0
- package/dist/sgr-CMfEpjSk.d.ts +0 -91
- package/dist/truncate-Cr6xVFMa.js +0 -2330
- package/src/ansi/supports-color.ts +0 -207
- package/src/colorize.ts +0 -60
- package/src/log-update.ts +0 -370
- package/src/output.ts +0 -308
- package/src/renderer.ts +0 -73
|
@@ -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
|
+
});
|