terminfo.dev 3.1.2 → 3.3.0
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/package.json +1 -1
- package/src/detect.ts +4 -2
- package/src/index.ts +2 -2
- package/src/probes/index.ts +115 -64
- package/src/serve.ts +119 -32
- package/src/tty.ts +65 -2
package/package.json
CHANGED
package/src/detect.ts
CHANGED
|
@@ -97,8 +97,10 @@ export function detectTerminal(): TerminalInfo {
|
|
|
97
97
|
// Linux: check common env vars
|
|
98
98
|
if (name === "unknown") {
|
|
99
99
|
if (process.env.GNOME_TERMINAL_SCREEN) name = "gnome-terminal"
|
|
100
|
-
else if (process.env.KONSOLE_VERSION) {
|
|
101
|
-
|
|
100
|
+
else if (process.env.KONSOLE_VERSION) {
|
|
101
|
+
name = "konsole"
|
|
102
|
+
version = process.env.KONSOLE_VERSION
|
|
103
|
+
} else if (process.env.TILIX_ID) name = "tilix"
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
// Windows: check for Windows Terminal
|
package/src/index.ts
CHANGED
|
@@ -272,10 +272,10 @@ program
|
|
|
272
272
|
console.log(`\x1b[31m✗ HTTP ${res.status}\x1b[0m`)
|
|
273
273
|
continue
|
|
274
274
|
}
|
|
275
|
-
const data = await res.json() as any
|
|
275
|
+
const data = (await res.json()) as any
|
|
276
276
|
const passed = Object.values(data.results).filter((v: any) => v).length
|
|
277
277
|
const total = Object.keys(data.results).length
|
|
278
|
-
const pct = Math.round(passed / total * 100)
|
|
278
|
+
const pct = Math.round((passed / total) * 100)
|
|
279
279
|
const color = pct >= 98 ? "\x1b[32m" : pct >= 90 ? "\x1b[33m" : "\x1b[31m"
|
|
280
280
|
console.log(`${color}${passed}/${total} (${pct}%)\x1b[0m`)
|
|
281
281
|
|
package/src/probes/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* 4. Query/response — send query sequence, match response pattern
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { query, queryCursorPosition, measureRenderedWidth, queryMode } from "../tty.ts"
|
|
15
|
+
import { query, queryWithSentinel, queryCursorPosition, measureRenderedWidth, queryMode } from "../tty.ts"
|
|
16
16
|
|
|
17
17
|
export interface ProbeResult {
|
|
18
18
|
pass: boolean
|
|
@@ -379,6 +379,58 @@ const wideCharEmoji: Probe = {
|
|
|
379
379
|
},
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
const wideCharEmojiZwj: Probe = {
|
|
383
|
+
id: "text.wide.emoji-zwj",
|
|
384
|
+
name: "Emoji ZWJ sequence width",
|
|
385
|
+
async run() {
|
|
386
|
+
const width = await measureRenderedWidth("👨👩👧👦")
|
|
387
|
+
if (width === null) return { pass: false, note: "Cannot measure width" }
|
|
388
|
+
return {
|
|
389
|
+
pass: width === 2,
|
|
390
|
+
note: width === 2 ? undefined : `width=${width}, expected 2`,
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const wideCharEmojiFlags: Probe = {
|
|
396
|
+
id: "text.wide.emoji-flags",
|
|
397
|
+
name: "Regional indicator flag width",
|
|
398
|
+
async run() {
|
|
399
|
+
const width = await measureRenderedWidth("🇺🇸")
|
|
400
|
+
if (width === null) return { pass: false, note: "Cannot measure width" }
|
|
401
|
+
return {
|
|
402
|
+
pass: width === 2,
|
|
403
|
+
note: width === 2 ? undefined : `width=${width}, expected 2`,
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const wideCharEmojiVs16: Probe = {
|
|
409
|
+
id: "text.wide.emoji-vs16",
|
|
410
|
+
name: "Variation selector 16 width",
|
|
411
|
+
async run() {
|
|
412
|
+
const width = await measureRenderedWidth("☺\uFE0F")
|
|
413
|
+
if (width === null) return { pass: false, note: "Cannot measure width" }
|
|
414
|
+
return {
|
|
415
|
+
pass: width === 2,
|
|
416
|
+
note: width === 2 ? undefined : `width=${width}, expected 2`,
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const combiningChars: Probe = {
|
|
422
|
+
id: "text.combining",
|
|
423
|
+
name: "Combining character width",
|
|
424
|
+
async run() {
|
|
425
|
+
const width = await measureRenderedWidth("e\u0301")
|
|
426
|
+
if (width === null) return { pass: false, note: "Cannot measure width" }
|
|
427
|
+
return {
|
|
428
|
+
pass: width === 1,
|
|
429
|
+
note: width === 1 ? undefined : `width=${width}, expected 1`,
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
}
|
|
433
|
+
|
|
382
434
|
const tabStop: Probe = {
|
|
383
435
|
id: "text.tab",
|
|
384
436
|
name: "Tab stop (default 8-col)",
|
|
@@ -975,7 +1027,7 @@ const kittyKeyboard: Probe = {
|
|
|
975
1027
|
name: "Kitty keyboard protocol",
|
|
976
1028
|
async run() {
|
|
977
1029
|
// Query current keyboard mode flags — terminal responds with CSI ? flags u
|
|
978
|
-
const match = await
|
|
1030
|
+
const match = await queryWithSentinel("\x1b[?u", /\x1b\[\?(\d+)u/)
|
|
979
1031
|
if (!match) return { pass: false, note: "No kitty keyboard response" }
|
|
980
1032
|
return { pass: true, response: `flags=${match[1]}` }
|
|
981
1033
|
},
|
|
@@ -1045,7 +1097,7 @@ const osc10FgColor: Probe = {
|
|
|
1045
1097
|
id: "extensions.osc10-fg-color",
|
|
1046
1098
|
name: "Foreground color query (OSC 10)",
|
|
1047
1099
|
async run() {
|
|
1048
|
-
const match = await
|
|
1100
|
+
const match = await queryWithSentinel("\x1b]10;?\x07", /\x1b\]10;([^\x07\x1b]+)[\x07\x1b]/)
|
|
1049
1101
|
if (!match) return { pass: false, note: "No OSC 10 response" }
|
|
1050
1102
|
return { pass: true, response: match[1] }
|
|
1051
1103
|
},
|
|
@@ -1055,7 +1107,7 @@ const osc11BgColor: Probe = {
|
|
|
1055
1107
|
id: "extensions.osc11-bg-color",
|
|
1056
1108
|
name: "Background color query (OSC 11)",
|
|
1057
1109
|
async run() {
|
|
1058
|
-
const match = await
|
|
1110
|
+
const match = await queryWithSentinel("\x1b]11;?\x07", /\x1b\]11;([^\x07\x1b]+)[\x07\x1b]/)
|
|
1059
1111
|
if (!match) return { pass: false, note: "No OSC 11 response" }
|
|
1060
1112
|
return { pass: true, response: match[1] }
|
|
1061
1113
|
},
|
|
@@ -1170,6 +1222,10 @@ export const ALL_PROBES: Probe[] = [
|
|
|
1170
1222
|
textNextLine,
|
|
1171
1223
|
wideCharCJK,
|
|
1172
1224
|
wideCharEmoji,
|
|
1225
|
+
wideCharEmojiZwj,
|
|
1226
|
+
wideCharEmojiFlags,
|
|
1227
|
+
wideCharEmojiVs16,
|
|
1228
|
+
combiningChars,
|
|
1173
1229
|
tabStop,
|
|
1174
1230
|
backspace,
|
|
1175
1231
|
|
|
@@ -1199,67 +1255,60 @@ export const ALL_PROBES: Probe[] = [
|
|
|
1199
1255
|
|
|
1200
1256
|
// ── Modes (DECRPM with behavioral fallback) ──
|
|
1201
1257
|
behavioralModeProbe(
|
|
1202
|
-
"modes.mouse-tracking",
|
|
1203
|
-
"
|
|
1258
|
+
"modes.mouse-tracking",
|
|
1259
|
+
"Mouse tracking (DECSET 1000)",
|
|
1260
|
+
1000,
|
|
1261
|
+
"\x1b[?1000h",
|
|
1262
|
+
"\x1b[?1000l",
|
|
1204
1263
|
async () => {
|
|
1205
1264
|
// Enable mouse tracking, verify terminal still responds
|
|
1206
1265
|
const pos = await queryCursorPosition()
|
|
1207
1266
|
return { pass: pos !== null, note: pos ? "Behavioral: responsive after enable" : "No response" }
|
|
1208
1267
|
},
|
|
1209
1268
|
),
|
|
1269
|
+
behavioralModeProbe("modes.mouse-sgr", "SGR mouse (DECSET 1006)", 1006, "\x1b[?1006h", "\x1b[?1006l", async () => {
|
|
1270
|
+
const pos = await queryCursorPosition()
|
|
1271
|
+
return { pass: pos !== null, note: pos ? "Behavioral: responsive after enable" : "No response" }
|
|
1272
|
+
}),
|
|
1210
1273
|
behavioralModeProbe(
|
|
1211
|
-
"modes.
|
|
1212
|
-
"
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
},
|
|
1217
|
-
),
|
|
1218
|
-
behavioralModeProbe(
|
|
1219
|
-
"modes.focus-tracking", "Focus tracking (DECSET 1004)", 1004,
|
|
1220
|
-
"\x1b[?1004h", "\x1b[?1004l",
|
|
1221
|
-
async () => {
|
|
1222
|
-
const pos = await queryCursorPosition()
|
|
1223
|
-
return { pass: pos !== null, note: pos ? "Behavioral: responsive after enable" : "No response" }
|
|
1224
|
-
},
|
|
1225
|
-
),
|
|
1226
|
-
behavioralModeProbe(
|
|
1227
|
-
"modes.application-cursor", "App cursor keys (DECCKM)", 1,
|
|
1228
|
-
"\x1b[?1h", "\x1b[?1l",
|
|
1229
|
-
async () => {
|
|
1230
|
-
// In DECCKM mode, arrow keys send ESC O A instead of ESC [ A
|
|
1231
|
-
// Can't test without pressing keys — just verify responsive
|
|
1232
|
-
const pos = await queryCursorPosition()
|
|
1233
|
-
return { pass: pos !== null, note: pos ? "Behavioral: responsive after enable" : "No response" }
|
|
1234
|
-
},
|
|
1235
|
-
),
|
|
1236
|
-
behavioralModeProbe(
|
|
1237
|
-
"modes.origin", "Origin mode (DECOM)", 6,
|
|
1238
|
-
"\x1b[?6h", "\x1b[?6l",
|
|
1239
|
-
async () => {
|
|
1240
|
-
// In origin mode, cursor is relative to scroll region
|
|
1241
|
-
// Set scroll region, enable origin, move to 1;1, check actual position
|
|
1242
|
-
process.stdout.write("\x1b[5;10r") // scroll region rows 5-10
|
|
1243
|
-
const pos = await queryCursorPosition()
|
|
1244
|
-
process.stdout.write("\x1b[r") // reset scroll region
|
|
1245
|
-
if (!pos) return { pass: false, note: "No response" }
|
|
1246
|
-
// In origin mode, cursor 1;1 maps to row 5 (top of region)
|
|
1247
|
-
return { pass: pos[0] >= 5, note: `Behavioral: cursor at row ${pos[0]} (origin mapped)` }
|
|
1248
|
-
},
|
|
1249
|
-
),
|
|
1250
|
-
behavioralModeProbe(
|
|
1251
|
-
"modes.reverse-video", "Reverse video (DECSCNM)", 5,
|
|
1252
|
-
"\x1b[?5h", "\x1b[?5l",
|
|
1274
|
+
"modes.focus-tracking",
|
|
1275
|
+
"Focus tracking (DECSET 1004)",
|
|
1276
|
+
1004,
|
|
1277
|
+
"\x1b[?1004h",
|
|
1278
|
+
"\x1b[?1004l",
|
|
1253
1279
|
async () => {
|
|
1254
|
-
// Reverse video swaps fg/bg — can't verify visually via PTY
|
|
1255
|
-
// Just verify terminal is responsive after toggling
|
|
1256
1280
|
const pos = await queryCursorPosition()
|
|
1257
1281
|
return { pass: pos !== null, note: pos ? "Behavioral: responsive after enable" : "No response" }
|
|
1258
1282
|
},
|
|
1259
1283
|
),
|
|
1284
|
+
behavioralModeProbe("modes.application-cursor", "App cursor keys (DECCKM)", 1, "\x1b[?1h", "\x1b[?1l", async () => {
|
|
1285
|
+
// In DECCKM mode, arrow keys send ESC O A instead of ESC [ A
|
|
1286
|
+
// Can't test without pressing keys — just verify responsive
|
|
1287
|
+
const pos = await queryCursorPosition()
|
|
1288
|
+
return { pass: pos !== null, note: pos ? "Behavioral: responsive after enable" : "No response" }
|
|
1289
|
+
}),
|
|
1290
|
+
behavioralModeProbe("modes.origin", "Origin mode (DECOM)", 6, "\x1b[?6h", "\x1b[?6l", async () => {
|
|
1291
|
+
// In origin mode, cursor is relative to scroll region
|
|
1292
|
+
// Set scroll region, enable origin, move to 1;1, check actual position
|
|
1293
|
+
process.stdout.write("\x1b[5;10r") // scroll region rows 5-10
|
|
1294
|
+
const pos = await queryCursorPosition()
|
|
1295
|
+
process.stdout.write("\x1b[r") // reset scroll region
|
|
1296
|
+
if (!pos) return { pass: false, note: "No response" }
|
|
1297
|
+
// In origin mode, cursor 1;1 maps to row 5 (top of region)
|
|
1298
|
+
return { pass: pos[0] >= 5, note: `Behavioral: cursor at row ${pos[0]} (origin mapped)` }
|
|
1299
|
+
}),
|
|
1300
|
+
behavioralModeProbe("modes.reverse-video", "Reverse video (DECSCNM)", 5, "\x1b[?5h", "\x1b[?5l", async () => {
|
|
1301
|
+
// Reverse video swaps fg/bg — can't verify visually via PTY
|
|
1302
|
+
// Just verify terminal is responsive after toggling
|
|
1303
|
+
const pos = await queryCursorPosition()
|
|
1304
|
+
return { pass: pos !== null, note: pos ? "Behavioral: responsive after enable" : "No response" }
|
|
1305
|
+
}),
|
|
1260
1306
|
behavioralModeProbe(
|
|
1261
|
-
"modes.synchronized-output",
|
|
1262
|
-
"
|
|
1307
|
+
"modes.synchronized-output",
|
|
1308
|
+
"Synchronized output (DECSET 2026)",
|
|
1309
|
+
2026,
|
|
1310
|
+
"\x1b[?2026h",
|
|
1311
|
+
"\x1b[?2026l",
|
|
1263
1312
|
async () => {
|
|
1264
1313
|
// Synchronized output batches rendering — just verify responsive
|
|
1265
1314
|
const pos = await queryCursorPosition()
|
|
@@ -1341,14 +1390,10 @@ export const ALL_PROBES: Probe[] = [
|
|
|
1341
1390
|
// APC G with a=T (transmit), f=100 (PNG), s=1, v=1, payload=minimal
|
|
1342
1391
|
// The terminal responds with APC G if it supports the protocol
|
|
1343
1392
|
const payload = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" // 1x1 red PNG
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
const match = await query("", /\x1b_G([^\x1b]*)\x1b\\/, 1000)
|
|
1393
|
+
// Send payload, then use DA1 sentinel to detect support quickly
|
|
1394
|
+
const match = await queryWithSentinel(`\x1b_Ga=T,f=100,s=1,v=1,t=d;${payload}\x1b\\`, /\x1b_G([^\x1b]*)\x1b\\/)
|
|
1347
1395
|
if (match) return { pass: true, response: match[1] }
|
|
1348
|
-
|
|
1349
|
-
const pos = await queryCursorPosition()
|
|
1350
|
-
if (!pos) return { pass: false, note: "No response after kitty graphics payload" }
|
|
1351
|
-
return { pass: false, note: "Terminal responsive but no kitty graphics acknowledgment" }
|
|
1396
|
+
return { pass: false, note: "No kitty graphics acknowledgment" }
|
|
1352
1397
|
},
|
|
1353
1398
|
} satisfies Probe,
|
|
1354
1399
|
|
|
@@ -1361,7 +1406,7 @@ export const ALL_PROBES: Probe[] = [
|
|
|
1361
1406
|
name: "Text reflow on resize",
|
|
1362
1407
|
async run() {
|
|
1363
1408
|
// Check if terminal reports its size (needed for reflow to work)
|
|
1364
|
-
const sizeMatch = await
|
|
1409
|
+
const sizeMatch = await queryWithSentinel("\x1b[18t", /\x1b\[8;(\d+);(\d+)t/)
|
|
1365
1410
|
if (!sizeMatch) return { pass: false, note: "No XTWINOPS 18 response (can't report size)" }
|
|
1366
1411
|
const cols = parseInt(sizeMatch[2]!, 10)
|
|
1367
1412
|
// Write a line longer than terminal width — verify it wraps correctly
|
|
@@ -1384,7 +1429,7 @@ export const ALL_PROBES: Probe[] = [
|
|
|
1384
1429
|
name: "Scrollback accumulates",
|
|
1385
1430
|
async run() {
|
|
1386
1431
|
// Get terminal height first
|
|
1387
|
-
const sizeMatch = await
|
|
1432
|
+
const sizeMatch = await queryWithSentinel("\x1b[18t", /\x1b\[8;(\d+);(\d+)t/)
|
|
1388
1433
|
const rows = sizeMatch ? parseInt(sizeMatch[1]!, 10) : 24
|
|
1389
1434
|
process.stdout.write("\x1b[2J\x1b[H") // clear + home
|
|
1390
1435
|
// Write more lines than the screen can hold
|
|
@@ -1444,7 +1489,10 @@ export const ALL_PROBES: Probe[] = [
|
|
|
1444
1489
|
if (!pos2) return { pass: false, note: "No cursor response after alt screen exit" }
|
|
1445
1490
|
return {
|
|
1446
1491
|
pass: pos2[0] === pos1[0] && pos2[1] === pos1[1],
|
|
1447
|
-
note:
|
|
1492
|
+
note:
|
|
1493
|
+
pos2[0] === pos1[0] && pos2[1] === pos1[1]
|
|
1494
|
+
? undefined
|
|
1495
|
+
: `cursor at ${pos2[0]};${pos2[1]}, expected ${pos1[0]};${pos1[1]}`,
|
|
1448
1496
|
}
|
|
1449
1497
|
},
|
|
1450
1498
|
} satisfies Probe,
|
|
@@ -1465,8 +1513,11 @@ export const ALL_PROBES: Probe[] = [
|
|
|
1465
1513
|
|
|
1466
1514
|
// Mouse all-motion (DECSET 1003)
|
|
1467
1515
|
behavioralModeProbe(
|
|
1468
|
-
"modes.mouse-all",
|
|
1469
|
-
"
|
|
1516
|
+
"modes.mouse-all",
|
|
1517
|
+
"All-motion mouse tracking (DECSET 1003)",
|
|
1518
|
+
1003,
|
|
1519
|
+
"\x1b[?1003h",
|
|
1520
|
+
"\x1b[?1003l",
|
|
1470
1521
|
async () => {
|
|
1471
1522
|
const pos = await queryCursorPosition()
|
|
1472
1523
|
return { pass: pos !== null, note: pos ? "Behavioral: responsive after enable" : "No response" }
|
package/src/serve.ts
CHANGED
|
@@ -40,12 +40,14 @@ function register(info: DaemonInfo): string {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
function unregister(filepath: string) {
|
|
43
|
-
try {
|
|
43
|
+
try {
|
|
44
|
+
unlinkSync(filepath)
|
|
45
|
+
} catch {}
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export function listDaemons(): DaemonInfo[] {
|
|
47
49
|
try {
|
|
48
|
-
const files = readdirSync(DAEMON_DIR).filter(f => f.endsWith(".json"))
|
|
50
|
+
const files = readdirSync(DAEMON_DIR).filter((f) => f.endsWith(".json"))
|
|
49
51
|
const daemons: DaemonInfo[] = []
|
|
50
52
|
for (const f of files) {
|
|
51
53
|
try {
|
|
@@ -71,13 +73,15 @@ export async function startDaemon(port = 0): Promise<void> {
|
|
|
71
73
|
const url = new URL(req.url ?? "/", `http://localhost`)
|
|
72
74
|
|
|
73
75
|
if (url.pathname === "/info") {
|
|
74
|
-
res.end(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
res.end(
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
terminal: terminal.name,
|
|
79
|
+
terminalVersion: terminal.version,
|
|
80
|
+
os: terminal.os,
|
|
81
|
+
osVersion: terminal.osVersion,
|
|
82
|
+
probeCount: ALL_PROBES.length,
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
81
85
|
return
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -109,21 +113,23 @@ export async function startDaemon(port = 0): Promise<void> {
|
|
|
109
113
|
// Reset terminal after probes
|
|
110
114
|
process.stdout.write("\x1bc")
|
|
111
115
|
|
|
112
|
-
const passed = Object.values(results).filter(v => v).length
|
|
116
|
+
const passed = Object.values(results).filter((v) => v).length
|
|
113
117
|
const total = Object.keys(results).length
|
|
114
|
-
console.log(`\x1b[32m✓\x1b[0m ${passed}/${total} (${Math.round(passed / total * 100)}%)`)
|
|
118
|
+
console.log(`\x1b[32m✓\x1b[0m ${passed}/${total} (${Math.round((passed / total) * 100)}%)`)
|
|
115
119
|
|
|
116
|
-
res.end(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
120
|
+
res.end(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
terminal: terminal.name,
|
|
123
|
+
terminalVersion: terminal.version,
|
|
124
|
+
os: terminal.os,
|
|
125
|
+
osVersion: terminal.osVersion,
|
|
126
|
+
source: "daemon",
|
|
127
|
+
generated: new Date().toISOString(),
|
|
128
|
+
results,
|
|
129
|
+
notes,
|
|
130
|
+
responses,
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
127
133
|
return
|
|
128
134
|
}
|
|
129
135
|
|
|
@@ -134,7 +140,7 @@ export async function startDaemon(port = 0): Promise<void> {
|
|
|
134
140
|
res.end(JSON.stringify({ error: "Missing ?id= parameter" }))
|
|
135
141
|
return
|
|
136
142
|
}
|
|
137
|
-
const probe = ALL_PROBES.find(p => p.id === probeId)
|
|
143
|
+
const probe = ALL_PROBES.find((p) => p.id === probeId)
|
|
138
144
|
if (!probe) {
|
|
139
145
|
res.statusCode = 404
|
|
140
146
|
res.end(JSON.stringify({ error: `Unknown probe: ${probeId}` }))
|
|
@@ -158,16 +164,78 @@ export async function startDaemon(port = 0): Promise<void> {
|
|
|
158
164
|
return
|
|
159
165
|
}
|
|
160
166
|
|
|
167
|
+
if (url.pathname === "/exec" && req.method === "POST") {
|
|
168
|
+
// Execute raw escape sequence commands in this terminal
|
|
169
|
+
// POST body: { commands: [{ write: "\\x1b[6n", read: "\\x1b\\[(\\d+);(\\d+)R", timeout?: 1000 }, ...] }
|
|
170
|
+
const body = await readBody(req)
|
|
171
|
+
try {
|
|
172
|
+
const { commands } = JSON.parse(body) as {
|
|
173
|
+
commands: Array<{ write?: string; read?: string; timeout?: number; measure?: string }>
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(commands)) {
|
|
176
|
+
res.statusCode = 400
|
|
177
|
+
res.end(JSON.stringify({ error: "commands must be an array" }))
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const results: Array<{ response?: string | null; width?: number | null; error?: string }> = []
|
|
182
|
+
|
|
183
|
+
await withRawMode(async () => {
|
|
184
|
+
process.stdout.write("\x1b[0m\x1b[2J\x1b[H")
|
|
185
|
+
for (const cmd of commands) {
|
|
186
|
+
try {
|
|
187
|
+
if (cmd.measure) {
|
|
188
|
+
// Measure rendered width of a string
|
|
189
|
+
const { measureRenderedWidth } = await import("./tty.ts")
|
|
190
|
+
const width = await measureRenderedWidth(cmd.measure)
|
|
191
|
+
results.push({ width })
|
|
192
|
+
} else if (cmd.write && cmd.read) {
|
|
193
|
+
// Write sequence, read response
|
|
194
|
+
const { query } = await import("./tty.ts")
|
|
195
|
+
const match = await query(
|
|
196
|
+
unescapeSequence(cmd.write),
|
|
197
|
+
new RegExp(cmd.read),
|
|
198
|
+
cmd.timeout ?? 1000,
|
|
199
|
+
)
|
|
200
|
+
results.push({ response: match ? match[0] : null })
|
|
201
|
+
} else if (cmd.write) {
|
|
202
|
+
// Just write, no response expected
|
|
203
|
+
process.stdout.write(unescapeSequence(cmd.write))
|
|
204
|
+
results.push({ response: "ok" })
|
|
205
|
+
} else {
|
|
206
|
+
results.push({ error: "command needs write, read, or measure" })
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
results.push({ error: err instanceof Error ? err.message : String(err) })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
process.stdout.write("\x1b[0m\x1b[2J\x1b[H")
|
|
213
|
+
await drainStdin(500)
|
|
214
|
+
})
|
|
215
|
+
process.stdout.write("\x1bc")
|
|
216
|
+
|
|
217
|
+
console.log(`\x1b[2m[${new Date().toISOString()}] Executed ${commands.length} commands\x1b[0m`)
|
|
218
|
+
res.end(JSON.stringify({ terminal: terminal.name, results }))
|
|
219
|
+
} catch (err) {
|
|
220
|
+
res.statusCode = 400
|
|
221
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
222
|
+
}
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
161
226
|
// Default: show help
|
|
162
|
-
res.end(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
227
|
+
res.end(
|
|
228
|
+
JSON.stringify({
|
|
229
|
+
endpoints: {
|
|
230
|
+
"/info": "Terminal info",
|
|
231
|
+
"/probe": "Run all probes",
|
|
232
|
+
"/probe/single?id=sgr.bold": "Run single probe",
|
|
233
|
+
"/exec": "POST — execute raw escape sequence commands",
|
|
234
|
+
},
|
|
235
|
+
terminal: terminal.name,
|
|
236
|
+
version: terminal.version,
|
|
237
|
+
}),
|
|
238
|
+
)
|
|
171
239
|
})
|
|
172
240
|
|
|
173
241
|
server.listen(port, "127.0.0.1", () => {
|
|
@@ -209,3 +277,22 @@ export async function startDaemon(port = 0): Promise<void> {
|
|
|
209
277
|
process.on("SIGTERM", cleanup)
|
|
210
278
|
})
|
|
211
279
|
}
|
|
280
|
+
|
|
281
|
+
/** Read full request body */
|
|
282
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
283
|
+
return new Promise((resolve, reject) => {
|
|
284
|
+
const chunks: Buffer[] = []
|
|
285
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk))
|
|
286
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()))
|
|
287
|
+
req.on("error", reject)
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Convert \\x1b notation to actual escape characters */
|
|
292
|
+
function unescapeSequence(s: string): string {
|
|
293
|
+
return s.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
294
|
+
.replace(/\\e/g, "\x1b")
|
|
295
|
+
.replace(/\\n/g, "\n")
|
|
296
|
+
.replace(/\\r/g, "\r")
|
|
297
|
+
.replace(/\\t/g, "\t")
|
|
298
|
+
}
|
package/src/tty.ts
CHANGED
|
@@ -39,13 +39,75 @@ export function readResponse(pattern: RegExp, timeoutMs: number): Promise<string
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Send an escape sequence and read the response.
|
|
42
|
-
* Handles raw mode setup/teardown.
|
|
43
42
|
*/
|
|
44
43
|
export async function query(sequence: string, responsePattern: RegExp, timeoutMs = 1000): Promise<string[] | null> {
|
|
45
44
|
process.stdout.write(sequence)
|
|
46
45
|
return readResponse(responsePattern, timeoutMs)
|
|
47
46
|
}
|
|
48
47
|
|
|
48
|
+
/**
|
|
49
|
+
* DA1 response pattern — universally supported by all modern terminals.
|
|
50
|
+
* Used as a sentinel: if DA1 arrives without the expected response, the
|
|
51
|
+
* terminal doesn't support the queried feature.
|
|
52
|
+
*/
|
|
53
|
+
const DA1_PATTERN = /\x1b\[\?[0-9;]+c/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Query with DA1 sentinel — faster than timeout-based detection.
|
|
57
|
+
*
|
|
58
|
+
* Sends the query sequence followed by DA1 (ESC [ c). Reads responses
|
|
59
|
+
* looking for either the expected response OR the DA1 sentinel:
|
|
60
|
+
* - If the query response arrives first → feature is supported, return match
|
|
61
|
+
* - If DA1 arrives first (without query response) → not supported, return null
|
|
62
|
+
* - If timeout expires → return null (fallback safety net)
|
|
63
|
+
*
|
|
64
|
+
* Inspired by terminal-colorsaurus. Turns 1000ms timeouts into near-instant
|
|
65
|
+
* negative detection for unsupported features.
|
|
66
|
+
*/
|
|
67
|
+
export async function queryWithSentinel(
|
|
68
|
+
sequence: string,
|
|
69
|
+
responsePattern: RegExp,
|
|
70
|
+
timeoutMs = 2000,
|
|
71
|
+
): Promise<string[] | null> {
|
|
72
|
+
// Send query + DA1 sentinel back-to-back
|
|
73
|
+
process.stdout.write(sequence + "\x1b[c")
|
|
74
|
+
|
|
75
|
+
let buf = ""
|
|
76
|
+
let queryMatch: string[] | null = null
|
|
77
|
+
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
cleanup()
|
|
81
|
+
resolve(queryMatch)
|
|
82
|
+
}, timeoutMs)
|
|
83
|
+
|
|
84
|
+
function onData(chunk: Buffer) {
|
|
85
|
+
buf += chunk.toString()
|
|
86
|
+
|
|
87
|
+
// Always check for the query response (accumulates across chunks)
|
|
88
|
+
if (!queryMatch) {
|
|
89
|
+
const match = buf.match(responsePattern)
|
|
90
|
+
if (match) queryMatch = [...match]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// DA1 is the termination signal in all cases.
|
|
94
|
+
// - If query matched: DA1 confirms we've consumed the sentinel, safe to return
|
|
95
|
+
// - If query didn't match: DA1 means the terminal doesn't support the feature
|
|
96
|
+
if (DA1_PATTERN.test(buf)) {
|
|
97
|
+
cleanup()
|
|
98
|
+
resolve(queryMatch)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cleanup() {
|
|
103
|
+
clearTimeout(timer)
|
|
104
|
+
process.stdin.off("data", onData)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.stdin.on("data", onData)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
49
111
|
/**
|
|
50
112
|
* Query cursor position via DSR 6 (Device Status Report).
|
|
51
113
|
* Returns [row, col] (1-based) or null if no response.
|
|
@@ -71,10 +133,11 @@ export async function measureRenderedWidth(text: string): Promise<number | null>
|
|
|
71
133
|
|
|
72
134
|
/**
|
|
73
135
|
* Query whether a DEC private mode is recognized via DECRPM.
|
|
136
|
+
* Uses DA1 sentinel for fast negative detection.
|
|
74
137
|
* Returns "set", "reset", "unknown", or null (no response).
|
|
75
138
|
*/
|
|
76
139
|
export async function queryMode(modeNumber: number): Promise<"set" | "reset" | "unknown" | null> {
|
|
77
|
-
const match = await
|
|
140
|
+
const match = await queryWithSentinel(`\x1b[?${modeNumber}$p`, /\x1b\[\?(\d+);(\d+)\$y/)
|
|
78
141
|
if (!match) return null
|
|
79
142
|
const status = parseInt(match[2]!, 10)
|
|
80
143
|
switch (status) {
|