Repository navigation
Expand file tree
/
Copy pathterminfo.ts
More file actions
279 lines (243 loc) · 7.7 KB
/
Copy pathterminfo.ts
File metadata and controls
279 lines (243 loc) · 7.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import process from "node:process";
import { compiled } from "./wasm.ts";
import { offsets, struct, uint32 } from "./typedef.ts";
export const MAX_TERMINFO_ENTRY = 32768;
/* Flag bits — must match src/terminfo.h. */
const FLAG_TRUECOLOR = 1 << 0;
const FLAG_BACK_COLOR_ERASE = 1 << 1;
const FLAG_AUTO_RIGHT_MARGIN = 1 << 2;
const FLAG_EAT_NEWLINE_GLITCH = 1 << 3;
const FLAG_ALTSCREEN = 1 << 4;
const FLAG_STYLED_UNDERLINE = 1 << 5;
const TermInfoStruct = struct({
generation: uint32(),
colors: uint32(),
flags: uint32(),
confirmed: uint32(),
theme_fg: uint32(),
theme_bg: uint32(),
theme_cursor: uint32(),
});
const TI = offsets(TermInfoStruct);
/** 8-bit RGB, each channel 0–255. */
export interface Rgb {
r: number;
g: number;
b: number;
}
/** Static terminal capabilities, frozen at detection time. No WASM backing. */
export interface Capabilities {
readonly colors: number;
readonly trueColor: boolean;
readonly bce: boolean;
readonly autoMargin: boolean;
readonly xenl: boolean;
readonly altScreen: boolean;
readonly styledUnderline: boolean;
}
/** Opaque type alias for the raw terminfo key-sequence bytes. */
export type KeyTable = Uint8Array;
/** Result of detectTerminal(). */
export interface TerminalInfo {
/** Static capabilities, frozen at detection time. */
readonly capabilities: Capabilities;
/**
* Write to stdout immediately after detection. Probe responses arrive
* as CapabilityEvent values in input.scan().
*/
readonly probe: Uint8Array;
/** Raw terminfo key bytes. The input parser seeds its trie from this. */
readonly keys: KeyTable;
}
export interface DetectOptions {
/** Terminal name to resolve. Defaults to env.TERM. */
term?: string;
/**
* Environment for TERM/TERMINFO/TERMINFO_DIRS/HOME/COLORTERM lookups.
* Defaults to process.env. Injectable for testing.
*/
env?: Record<string, string | undefined>;
/** Raw compiled terminfo bytes; skips the filesystem lookup. */
entry?: Uint8Array;
signal?: AbortSignal;
}
const encoder = new TextEncoder();
const PROBE = encoder.encode(
"\x1b]10;?\x07" +
"\x1b]11;?\x07" +
"\x1b]12;?\x07" +
"\x1b]21;foreground=?;background=?;cursor=?\x1b\\" +
"\x1b]22;?__current__\x1b\\" +
"\x1bP+q524742;5463\x1b\\" +
"\x1b[?2026$p" +
"\x1b[?u" +
"\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" +
"\x1b[c",
);
const WASM_PAGE_BYTES = 65536;
function rgbOf(packed: number): Rgb {
return {
r: (packed >> 16) & 0xff,
g: (packed >> 8) & 0xff,
b: packed & 0xff,
};
}
/**
* Detect the terminal's capabilities from the compiled terminfo entry and
* process environment. Pure: performs no IO beyond reading the terminfo file.
* Rejects only when `entry` exceeds MAX_TERMINFO_ENTRY (caller error);
* environmental failures resolve to the baseline. See specs/terminfo-spec.md.
*/
export async function detectTerminal(
options: DetectOptions = {},
): Promise<TerminalInfo> {
let env = options.env ?? process.env;
let bytes = options.entry;
if (bytes && bytes.byteLength > MAX_TERMINFO_ENTRY) {
throw new RangeError(
`terminfo entry exceeds ${MAX_TERMINFO_ENTRY} byte limit (got ${bytes.byteLength})`,
);
}
if (!bytes) {
bytes = await loadTerminfo({ term: options.term, env });
}
let memory = new WebAssembly.Memory({ initial: 2 });
let instance = await WebAssembly.instantiate(compiled, {
env: { memory },
clay: {
measureTextFunction() {},
queryScrollOffsetFunction(ret: number) {
let v = new DataView(memory.buffer);
v.setFloat32(ret, 0, true);
v.setFloat32(ret + 4, 0, true);
},
},
});
let exports = instance.exports as unknown as {
__heap_base: WebAssembly.Global;
terminfo_size(): number;
terminfo_init(mem: number): number;
terminfo_parse(bytes: number, len: number, ti: number): number;
terminfo_grant(ti: number, flags: number): void;
};
let top = ((exports.__heap_base.value as number) + 7) & ~7;
function alloc(size: number): number {
let ptr = top;
top += (size + 7) & ~7;
let pages = Math.ceil(top / WASM_PAGE_BYTES);
let current = memory.buffer.byteLength / WASM_PAGE_BYTES;
if (pages > current) memory.grow(pages - current);
return ptr;
}
let structPtr = alloc(exports.terminfo_size());
exports.terminfo_init(structPtr);
let keys: KeyTable = new Uint8Array(0);
if (bytes) {
let bytesPtr = alloc(MAX_TERMINFO_ENTRY);
new Uint8Array(memory.buffer).set(bytes, bytesPtr);
if (exports.terminfo_parse(bytesPtr, bytes.byteLength, structPtr) === 0) {
keys = bytes.slice();
}
}
let colorterm = env.COLORTERM;
if (colorterm === "truecolor" || colorterm === "24bit") {
exports.terminfo_grant(structPtr, FLAG_TRUECOLOR);
}
let view = new DataView(memory.buffer);
let flags = view.getUint32(structPtr + TI.flags, true);
let capabilities: Capabilities = Object.freeze({
colors: view.getUint32(structPtr + TI.colors, true),
trueColor: !!(flags & FLAG_TRUECOLOR),
bce: !!(flags & FLAG_BACK_COLOR_ERASE),
autoMargin: !!(flags & FLAG_AUTO_RIGHT_MARGIN),
xenl: !!(flags & FLAG_EAT_NEWLINE_GLITCH),
altScreen: !!(flags & FLAG_ALTSCREEN),
styledUnderline: !!(flags & FLAG_STYLED_UNDERLINE),
});
return Object.freeze<TerminalInfo>({
capabilities,
probe: PROBE.slice(),
keys,
});
}
/* ── terminfo filesystem lookup ───────────────────────────────────── */
interface LoadOptions {
term?: string;
env: Record<string, string | undefined>;
}
const DEFAULT_DIRS = [
"/usr/share/terminfo",
"/etc/terminfo",
"/lib/terminfo",
"/usr/lib/terminfo",
];
const MAGIC_LEGACY = 0x011a;
const MAGIC_EXTENDED = 0x021e;
function hasTerminfoMagic(bytes: Uint8Array): boolean {
if (bytes.length < 2) return false;
let magic = bytes[0] | (bytes[1] << 8);
return magic === MAGIC_LEGACY || magic === MAGIC_EXTENDED;
}
function dirUrl(path: string): URL {
return pathToFileURL(path.endsWith("/") ? path : `${path}/`);
}
function searchPath(env: Record<string, string | undefined>): URL[] {
let dirs: URL[] = [];
let seen = new Set<string>();
let add = (url: URL): void => {
if (!seen.has(url.href)) {
seen.add(url.href);
dirs.push(url);
}
};
if (env.TERMINFO) add(dirUrl(env.TERMINFO));
if (env.HOME) add(new URL(".terminfo/", dirUrl(env.HOME)));
if (env.TERMINFO_DIRS) {
for (let entry of env.TERMINFO_DIRS.split(":")) {
if (entry === "") {
for (let dir of DEFAULT_DIRS) add(dirUrl(dir));
} else {
add(dirUrl(entry));
}
}
}
for (let dir of DEFAULT_DIRS) add(dirUrl(dir));
return dirs;
}
function candidates(base: URL, name: string): URL[] {
let first = name[0];
let hex = first.charCodeAt(0).toString(16).padStart(2, "0");
return [new URL(`${first}/${name}`, base), new URL(`${hex}/${name}`, base)];
}
async function tryRead(url: URL): Promise<Uint8Array | undefined> {
try {
return await readFile(url);
} catch {
return undefined;
}
}
async function loadTerminfo(
options: LoadOptions,
): Promise<Uint8Array | undefined> {
let name = options.term ?? options.env.TERM;
if (!name) return undefined;
if (
name.startsWith(".") || name.includes("/") || name.includes("\\") ||
name.includes("\0")
) {
return undefined;
}
for (let base of searchPath(options.env)) {
for (let url of candidates(base, name)) {
let b = await tryRead(url);
if (b && b.byteLength <= MAX_TERMINFO_ENTRY && hasTerminfoMagic(b)) {
return b;
}
}
}
return undefined;
}
/* Re-export Rgb helper for consumers that need it. */
export { rgbOf };