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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminfo.dev",
3
- "version": "3.1.2",
3
+ "version": "3.3.0",
4
4
  "description": "Test your terminal's feature support and contribute to terminfo.dev",
5
5
  "keywords": [
6
6
  "ansi",
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) { name = "konsole"; version = process.env.KONSOLE_VERSION }
101
- else if (process.env.TILIX_ID) name = "tilix"
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
 
@@ -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 query("\x1b[?u", /\x1b\[\?(\d+)u/, 1000)
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 query("\x1b]10;?\x07", /\x1b\]10;([^\x07\x1b]+)[\x07\x1b]/, 1000)
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 query("\x1b]11;?\x07", /\x1b\]11;([^\x07\x1b]+)[\x07\x1b]/, 1000)
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", "Mouse tracking (DECSET 1000)", 1000,
1203
- "\x1b[?1000h", "\x1b[?1000l",
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.mouse-sgr", "SGR mouse (DECSET 1006)", 1006,
1212
- "\x1b[?1006h", "\x1b[?1006l",
1213
- async () => {
1214
- const pos = await queryCursorPosition()
1215
- return { pass: pos !== null, note: pos ? "Behavioral: responsive after enable" : "No response" }
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", "Synchronized output (DECSET 2026)", 2026,
1262
- "\x1b[?2026h", "\x1b[?2026l",
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
- process.stdout.write(`\x1b_Ga=T,f=100,s=1,v=1,t=d;${payload}\x1b\\`)
1345
- // Check for kitty graphics response: ESC _ G ... ESC backslash
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
- // Fallback: check if cursor still responds (terminal didn't crash)
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 query("\x1b[18t", /\x1b\[8;(\d+);(\d+)t/, 1000)
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 query("\x1b[18t", /\x1b\[8;(\d+);(\d+)t/, 1000)
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: pos2[0] === pos1[0] && pos2[1] === pos1[1] ? undefined : `cursor at ${pos2[0]};${pos2[1]}, expected ${pos1[0]};${pos1[1]}`,
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", "All-motion mouse tracking (DECSET 1003)", 1003,
1469
- "\x1b[?1003h", "\x1b[?1003l",
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 { unlinkSync(filepath) } catch {}
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(JSON.stringify({
75
- terminal: terminal.name,
76
- terminalVersion: terminal.version,
77
- os: terminal.os,
78
- osVersion: terminal.osVersion,
79
- probeCount: ALL_PROBES.length,
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(JSON.stringify({
117
- terminal: terminal.name,
118
- terminalVersion: terminal.version,
119
- os: terminal.os,
120
- osVersion: terminal.osVersion,
121
- source: "daemon",
122
- generated: new Date().toISOString(),
123
- results,
124
- notes,
125
- responses,
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(JSON.stringify({
163
- endpoints: {
164
- "/info": "Terminal info",
165
- "/probe": "Run all probes",
166
- "/probe/single?id=sgr.bold": "Run single probe",
167
- },
168
- terminal: terminal.name,
169
- version: terminal.version,
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 query(`\x1b[?${modeNumber}$p`, /\x1b\[\?(\d+);(\d+)\$y/)
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) {