terminfo.dev 0.1.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 +37 -0
- package/src/detect.ts +103 -0
- package/src/index.ts +143 -0
- package/src/probes/index.ts +460 -0
- package/src/submit.ts +125 -0
- package/src/tty.ts +110 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "terminfo.dev",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Test your terminal's feature support and contribute to terminfo.dev",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ansi",
|
|
7
|
+
"conformance",
|
|
8
|
+
"escape-sequences",
|
|
9
|
+
"terminal",
|
|
10
|
+
"terminal-emulator",
|
|
11
|
+
"testing"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://terminfo.dev/",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/beorn/terminfo.dev/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Beorn",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/beorn/terminfo.dev.git",
|
|
22
|
+
"directory": "cli"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"terminfo": "./src/index.ts"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=23.6.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal detection — identify the running terminal emulator.
|
|
3
|
+
*
|
|
4
|
+
* Uses environment variables, DA2 response parsing, and fallback heuristics.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { release } from "node:os"
|
|
8
|
+
|
|
9
|
+
export interface TerminalInfo {
|
|
10
|
+
name: string
|
|
11
|
+
version: string
|
|
12
|
+
os: string
|
|
13
|
+
osVersion: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Known terminal detection via environment variables */
|
|
17
|
+
const ENV_DETECTORS: Array<{ env: string; name: string; versionEnv?: string }> = [
|
|
18
|
+
{ env: "GHOSTTY_RESOURCES_DIR", name: "ghostty" },
|
|
19
|
+
{ env: "KITTY_WINDOW_ID", name: "kitty" },
|
|
20
|
+
{ env: "WEZTERM_EXECUTABLE", name: "wezterm" },
|
|
21
|
+
{ env: "ALACRITTY_WINDOW_ID", name: "alacritty" },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/** Detect from $TERM_PROGRAM */
|
|
25
|
+
const TERM_PROGRAM_MAP: Record<string, string> = {
|
|
26
|
+
iTerm: "iterm2",
|
|
27
|
+
"iTerm.app": "iterm2",
|
|
28
|
+
Apple_Terminal: "terminal-app",
|
|
29
|
+
WezTerm: "wezterm",
|
|
30
|
+
vscode: "vscode",
|
|
31
|
+
Hyper: "hyper",
|
|
32
|
+
tmux: "tmux",
|
|
33
|
+
WarpTerminal: "warp",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function detectTerminal(): TerminalInfo {
|
|
37
|
+
const os = detectOS()
|
|
38
|
+
const osVersion = detectOSVersion()
|
|
39
|
+
|
|
40
|
+
// Check specific env vars first
|
|
41
|
+
for (const { env, name } of ENV_DETECTORS) {
|
|
42
|
+
if (process.env[env]) {
|
|
43
|
+
return { name, version: "", os, osVersion }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check $TERM_PROGRAM
|
|
48
|
+
const termProgram = process.env.TERM_PROGRAM
|
|
49
|
+
if (termProgram) {
|
|
50
|
+
const name = TERM_PROGRAM_MAP[termProgram] ?? termProgram.toLowerCase()
|
|
51
|
+
const version = process.env.TERM_PROGRAM_VERSION ?? ""
|
|
52
|
+
return { name, version, os, osVersion }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check $TERMINAL_EMULATOR (set by some Linux terminals)
|
|
56
|
+
const termEmu = process.env.TERMINAL_EMULATOR
|
|
57
|
+
if (termEmu) {
|
|
58
|
+
return { name: termEmu.toLowerCase(), version: "", os, osVersion }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fallback: use $TERM
|
|
62
|
+
const term = process.env.TERM ?? "unknown"
|
|
63
|
+
return { name: term, version: "", os, osVersion }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detectOS(): string {
|
|
67
|
+
switch (process.platform) {
|
|
68
|
+
case "darwin":
|
|
69
|
+
return "macos"
|
|
70
|
+
case "linux":
|
|
71
|
+
return "linux"
|
|
72
|
+
case "win32":
|
|
73
|
+
return "windows"
|
|
74
|
+
default:
|
|
75
|
+
return process.platform
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function detectOSVersion(): string {
|
|
80
|
+
try {
|
|
81
|
+
return release()
|
|
82
|
+
} catch {
|
|
83
|
+
return ""
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Query terminal identity via DA2 (Secondary Device Attributes).
|
|
89
|
+
* Sends CSI > 0 c and parses the response.
|
|
90
|
+
*
|
|
91
|
+
* Must be called with raw mode enabled on stdin.
|
|
92
|
+
*/
|
|
93
|
+
export async function queryDA2(
|
|
94
|
+
readResponse: (pattern: RegExp, timeoutMs: number) => Promise<string[] | null>,
|
|
95
|
+
): Promise<{ terminalId: number; version: number } | null> {
|
|
96
|
+
process.stdout.write("\x1b[>0c")
|
|
97
|
+
const match = await readResponse(/\x1b\[>(\d+);(\d+);(\d+)c/, 1000)
|
|
98
|
+
if (!match) return null
|
|
99
|
+
return {
|
|
100
|
+
terminalId: parseInt(match[1]!, 10),
|
|
101
|
+
version: parseInt(match[2]!, 10),
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* terminfo CLI — test your terminal's feature support.
|
|
4
|
+
*
|
|
5
|
+
* Runs probes against the real terminal you're using (not a headless library),
|
|
6
|
+
* shows results, and optionally submits them to terminfo.dev.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```bash
|
|
10
|
+
* npx terminfo # Run all probes
|
|
11
|
+
* npx terminfo --json # Output JSON results
|
|
12
|
+
* npx terminfo --submit # Submit results to terminfo.dev
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { detectTerminal } from "./detect.ts"
|
|
17
|
+
import { ALL_PROBES } from "./probes/index.ts"
|
|
18
|
+
import { withRawMode } from "./tty.ts"
|
|
19
|
+
import { submitResults } from "./submit.ts"
|
|
20
|
+
|
|
21
|
+
interface ResultEntry {
|
|
22
|
+
terminal: string
|
|
23
|
+
terminalVersion: string
|
|
24
|
+
os: string
|
|
25
|
+
osVersion: string
|
|
26
|
+
source: "community"
|
|
27
|
+
generated: string
|
|
28
|
+
results: Record<string, boolean>
|
|
29
|
+
notes: Record<string, string>
|
|
30
|
+
responses: Record<string, string>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
const args = process.argv.slice(2)
|
|
35
|
+
const jsonMode = args.includes("--json")
|
|
36
|
+
const submitMode = args.includes("--submit")
|
|
37
|
+
|
|
38
|
+
// Detect terminal
|
|
39
|
+
const terminal = detectTerminal()
|
|
40
|
+
|
|
41
|
+
if (!jsonMode) {
|
|
42
|
+
console.log(`\x1b[1mterminfo\x1b[0m — terminal feature testing for terminfo.dev\n`)
|
|
43
|
+
console.log(
|
|
44
|
+
`Detected: \x1b[1m${terminal.name}\x1b[0m${terminal.version ? ` ${terminal.version}` : ""} on ${terminal.os}${terminal.osVersion ? ` ${terminal.osVersion}` : ""}`,
|
|
45
|
+
)
|
|
46
|
+
console.log(`Running ${ALL_PROBES.length} probes...\n`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Save/restore screen state
|
|
50
|
+
process.stdout.write("\x1b7") // save cursor
|
|
51
|
+
process.stdout.write("\x1b[?1049h") // alt screen
|
|
52
|
+
process.stdout.write("\x1b[2J\x1b[H") // clear + home
|
|
53
|
+
|
|
54
|
+
const results: Record<string, boolean> = {}
|
|
55
|
+
const notes: Record<string, string> = {}
|
|
56
|
+
const responses: Record<string, string> = {}
|
|
57
|
+
let passed = 0
|
|
58
|
+
let failed = 0
|
|
59
|
+
|
|
60
|
+
await withRawMode(async () => {
|
|
61
|
+
for (const probe of ALL_PROBES) {
|
|
62
|
+
try {
|
|
63
|
+
const result = await probe.run()
|
|
64
|
+
results[probe.id] = result.pass
|
|
65
|
+
if (result.note) notes[probe.id] = result.note
|
|
66
|
+
if (result.response) responses[probe.id] = result.response
|
|
67
|
+
if (result.pass) passed++
|
|
68
|
+
else failed++
|
|
69
|
+
} catch (err) {
|
|
70
|
+
results[probe.id] = false
|
|
71
|
+
notes[probe.id] = `error: ${err instanceof Error ? err.message : String(err)}`
|
|
72
|
+
failed++
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Restore screen
|
|
78
|
+
process.stdout.write("\x1b[?1049l") // exit alt screen
|
|
79
|
+
process.stdout.write("\x1b8") // restore cursor
|
|
80
|
+
|
|
81
|
+
const total = ALL_PROBES.length
|
|
82
|
+
const pct = Math.round((passed / total) * 100)
|
|
83
|
+
|
|
84
|
+
const entry: ResultEntry = {
|
|
85
|
+
terminal: terminal.name,
|
|
86
|
+
terminalVersion: terminal.version,
|
|
87
|
+
os: terminal.os,
|
|
88
|
+
osVersion: terminal.osVersion,
|
|
89
|
+
source: "community",
|
|
90
|
+
generated: new Date().toISOString(),
|
|
91
|
+
results,
|
|
92
|
+
notes,
|
|
93
|
+
responses,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (jsonMode) {
|
|
97
|
+
console.log(JSON.stringify(entry, null, 2))
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Display results
|
|
102
|
+
console.log(`\n\x1b[1mResults: ${passed}/${total} (${pct}%)\x1b[0m\n`)
|
|
103
|
+
|
|
104
|
+
// Show categories
|
|
105
|
+
const categories = new Map<string, Array<{ id: string; name: string; pass: boolean; note?: string }>>()
|
|
106
|
+
for (const probe of ALL_PROBES) {
|
|
107
|
+
const cat = probe.id.split(".")[0]!
|
|
108
|
+
if (!categories.has(cat)) categories.set(cat, [])
|
|
109
|
+
categories.get(cat)!.push({
|
|
110
|
+
id: probe.id,
|
|
111
|
+
name: probe.name,
|
|
112
|
+
pass: results[probe.id] ?? false,
|
|
113
|
+
note: notes[probe.id],
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const [cat, probes] of categories) {
|
|
118
|
+
const catPassed = probes.filter((p) => p.pass).length
|
|
119
|
+
const color = catPassed === probes.length ? "\x1b[32m" : catPassed > 0 ? "\x1b[33m" : "\x1b[31m"
|
|
120
|
+
console.log(`${color}${cat}\x1b[0m (${catPassed}/${probes.length})`)
|
|
121
|
+
for (const p of probes) {
|
|
122
|
+
const icon = p.pass ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"
|
|
123
|
+
const note = p.note ? ` \x1b[2m— ${p.note}\x1b[0m` : ""
|
|
124
|
+
console.log(` ${icon} ${p.name}${note}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (submitMode) {
|
|
129
|
+
console.log(`\nSubmitting results to terminfo.dev...`)
|
|
130
|
+
const url = await submitResults(entry)
|
|
131
|
+
if (url) {
|
|
132
|
+
console.log(`\x1b[32m✓ Issue created:\x1b[0m ${url}`)
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
console.log(`\n\x1b[2mSubmit results to terminfo.dev: npx terminfo --submit\x1b[0m`)
|
|
136
|
+
console.log(`\x1b[2mJSON output: npx terminfo --json\x1b[0m`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
main().catch((err) => {
|
|
141
|
+
console.error(err)
|
|
142
|
+
process.exit(1)
|
|
143
|
+
})
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-terminal probes — test actual terminal capabilities via PTY I/O.
|
|
3
|
+
*
|
|
4
|
+
* Unlike headless probes (which read cell state programmatically), these send
|
|
5
|
+
* escape sequences to stdout and read responses from stdin. They verify what
|
|
6
|
+
* the terminal *claims* to support, not just what a headless library exposes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { query, queryCursorPosition, measureRenderedWidth, queryMode } from "../tty.ts"
|
|
10
|
+
|
|
11
|
+
export interface ProbeResult {
|
|
12
|
+
pass: boolean
|
|
13
|
+
note?: string
|
|
14
|
+
response?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Probe {
|
|
18
|
+
id: string
|
|
19
|
+
name: string
|
|
20
|
+
run: () => Promise<ProbeResult>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Cursor probes ──
|
|
24
|
+
|
|
25
|
+
const cursorPositionReport: Probe = {
|
|
26
|
+
id: "cursor.position-report",
|
|
27
|
+
name: "Cursor position report (DSR 6)",
|
|
28
|
+
async run() {
|
|
29
|
+
process.stdout.write("\x1b[3;5H") // Move to row 3, col 5
|
|
30
|
+
const pos = await queryCursorPosition()
|
|
31
|
+
if (!pos) return { pass: false, note: "No DSR 6 response" }
|
|
32
|
+
return {
|
|
33
|
+
pass: pos[0] === 3 && pos[1] === 5,
|
|
34
|
+
note: pos[0] === 3 && pos[1] === 5 ? undefined : `got ${pos[0]};${pos[1]}, expected 3;5`,
|
|
35
|
+
response: `${pos[0]};${pos[1]}`,
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cursorShape: Probe = {
|
|
41
|
+
id: "cursor.shape",
|
|
42
|
+
name: "Cursor shape (DECSCUSR)",
|
|
43
|
+
async run() {
|
|
44
|
+
// Set cursor to bar shape, then query via DECRPM-like
|
|
45
|
+
// Most terminals accept DECSCUSR but we can't read shape back via PTY
|
|
46
|
+
// Instead, verify the sequence doesn't crash and cursor still responds
|
|
47
|
+
process.stdout.write("\x1b[5 q") // blinking bar
|
|
48
|
+
const pos = await queryCursorPosition()
|
|
49
|
+
process.stdout.write("\x1b[0 q") // restore default
|
|
50
|
+
return {
|
|
51
|
+
pass: pos !== null,
|
|
52
|
+
note: pos ? undefined : "No response after DECSCUSR",
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Device probes ──
|
|
58
|
+
|
|
59
|
+
const primaryDA: Probe = {
|
|
60
|
+
id: "device.primary-da",
|
|
61
|
+
name: "Primary device attributes (DA1)",
|
|
62
|
+
async run() {
|
|
63
|
+
const match = await query("\x1b[c", /\x1b\[\?([0-9;]+)c/, 1000)
|
|
64
|
+
if (!match) return { pass: false, note: "No DA1 response" }
|
|
65
|
+
return { pass: true, response: match[0] }
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const deviceStatusReport: Probe = {
|
|
70
|
+
id: "device.status-report",
|
|
71
|
+
name: "Device status report (DSR 5)",
|
|
72
|
+
async run() {
|
|
73
|
+
const match = await query("\x1b[5n", /\x1b\[(\d+)n/, 1000)
|
|
74
|
+
if (!match) return { pass: false, note: "No DSR 5 response" }
|
|
75
|
+
return {
|
|
76
|
+
pass: match[1] === "0",
|
|
77
|
+
note: match[1] === "0" ? undefined : `status ${match[1]}`,
|
|
78
|
+
response: match[0],
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Mode probes (DECRPM) ──
|
|
84
|
+
|
|
85
|
+
function modeProbe(id: string, name: string, modeNum: number): Probe {
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
name,
|
|
89
|
+
async run() {
|
|
90
|
+
const result = await queryMode(modeNum)
|
|
91
|
+
if (result === null) return { pass: false, note: "No DECRPM response" }
|
|
92
|
+
return {
|
|
93
|
+
pass: result !== "unknown",
|
|
94
|
+
note: result === "unknown" ? "Mode not recognized" : `Mode ${result}`,
|
|
95
|
+
response: result,
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Text width probes ──
|
|
102
|
+
|
|
103
|
+
const wideCharCJK: Probe = {
|
|
104
|
+
id: "text.wide.cjk",
|
|
105
|
+
name: "CJK wide chars (2 cols)",
|
|
106
|
+
async run() {
|
|
107
|
+
const width = await measureRenderedWidth("中")
|
|
108
|
+
if (width === null) return { pass: false, note: "Cannot measure width" }
|
|
109
|
+
return {
|
|
110
|
+
pass: width === 2,
|
|
111
|
+
note: width === 2 ? undefined : `width=${width}, expected 2`,
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const wideCharEmoji: Probe = {
|
|
117
|
+
id: "text.wide.emoji",
|
|
118
|
+
name: "Emoji wide chars (2 cols)",
|
|
119
|
+
async run() {
|
|
120
|
+
const width = await measureRenderedWidth("😀")
|
|
121
|
+
if (width === null) return { pass: false, note: "Cannot measure width" }
|
|
122
|
+
return {
|
|
123
|
+
pass: width === 2,
|
|
124
|
+
note: width === 2 ? undefined : `width=${width}, expected 2`,
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── SGR probes (write + cursor position to verify parsing) ──
|
|
130
|
+
|
|
131
|
+
function sgrProbe(id: string, name: string, sequence: string): Probe {
|
|
132
|
+
return {
|
|
133
|
+
id,
|
|
134
|
+
name,
|
|
135
|
+
async run() {
|
|
136
|
+
// Write SGR sequence + text, verify cursor advances (sequence was parsed, not printed)
|
|
137
|
+
process.stdout.write("\x1b[1;1H\x1b[2K") // clear line
|
|
138
|
+
process.stdout.write(sequence + "X\x1b[0m")
|
|
139
|
+
const pos = await queryCursorPosition()
|
|
140
|
+
if (!pos) return { pass: false, note: "No cursor response" }
|
|
141
|
+
// Cursor should be at col 2 (wrote 1 char "X")
|
|
142
|
+
return {
|
|
143
|
+
pass: pos[1] === 2,
|
|
144
|
+
note: pos[1] === 2 ? undefined : `cursor at col ${pos[1]}, expected 2`,
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Cursor save/restore ──
|
|
151
|
+
|
|
152
|
+
const cursorSaveRestore: Probe = {
|
|
153
|
+
id: "cursor.save-restore",
|
|
154
|
+
name: "Cursor save/restore (DECSC/DECRC)",
|
|
155
|
+
async run() {
|
|
156
|
+
process.stdout.write("\x1b[3;5H") // Move to row 3, col 5
|
|
157
|
+
process.stdout.write("\x1b7") // DECSC — save cursor
|
|
158
|
+
process.stdout.write("\x1b[10;10H") // Move somewhere else
|
|
159
|
+
process.stdout.write("\x1b8") // DECRC — restore cursor
|
|
160
|
+
const pos = await queryCursorPosition()
|
|
161
|
+
if (!pos) return { pass: false, note: "No cursor response after restore" }
|
|
162
|
+
return {
|
|
163
|
+
pass: pos[0] === 3 && pos[1] === 5,
|
|
164
|
+
note: pos[0] === 3 && pos[1] === 5 ? undefined : `got ${pos[0]};${pos[1]}, expected 3;5`,
|
|
165
|
+
response: `${pos[0]};${pos[1]}`,
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Erase probes ──
|
|
171
|
+
|
|
172
|
+
const eraseLineRight: Probe = {
|
|
173
|
+
id: "erase.line.right",
|
|
174
|
+
name: "Erase line right (EL 0)",
|
|
175
|
+
async run() {
|
|
176
|
+
process.stdout.write("\x1b[1;1H\x1b[2K") // Clear line
|
|
177
|
+
process.stdout.write("ABCDE")
|
|
178
|
+
process.stdout.write("\x1b[1;3H") // Move to col 3
|
|
179
|
+
process.stdout.write("\x1b[0K") // EL 0 — erase to right
|
|
180
|
+
const pos = await queryCursorPosition()
|
|
181
|
+
if (!pos) return { pass: false, note: "No cursor response" }
|
|
182
|
+
return {
|
|
183
|
+
pass: pos[1] === 3,
|
|
184
|
+
note: pos[1] === 3 ? undefined : `cursor at col ${pos[1]}, expected 3`,
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const eraseScreenScrollback: Probe = {
|
|
190
|
+
id: "erase.screen.scrollback",
|
|
191
|
+
name: "Erase scrollback (ED 3)",
|
|
192
|
+
async run() {
|
|
193
|
+
process.stdout.write("\x1b[5;5H") // Move to known position
|
|
194
|
+
process.stdout.write("\x1b[3J") // ED 3 — erase scrollback
|
|
195
|
+
const pos = await queryCursorPosition()
|
|
196
|
+
if (!pos) return { pass: false, note: "No cursor response after ED 3" }
|
|
197
|
+
return {
|
|
198
|
+
pass: pos[0] === 5 && pos[1] === 5,
|
|
199
|
+
note: pos[0] === 5 && pos[1] === 5 ? undefined : `cursor at ${pos[0]};${pos[1]}, expected 5;5`,
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Scroll region ──
|
|
205
|
+
|
|
206
|
+
const scrollRegion: Probe = {
|
|
207
|
+
id: "scrollback.set-region",
|
|
208
|
+
name: "Scroll region (DECSTBM)",
|
|
209
|
+
async run() {
|
|
210
|
+
process.stdout.write("\x1b[5;10r") // Set scroll region rows 5–10
|
|
211
|
+
process.stdout.write("\x1b[r") // Reset scroll region
|
|
212
|
+
const pos = await queryCursorPosition()
|
|
213
|
+
if (!pos) return { pass: false, note: "No cursor response after DECSTBM" }
|
|
214
|
+
// After reset, cursor should still respond — terminal didn't crash
|
|
215
|
+
return { pass: true }
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Tab and backspace ──
|
|
220
|
+
|
|
221
|
+
const tabStop: Probe = {
|
|
222
|
+
id: "text.tab",
|
|
223
|
+
name: "Tab stop (default 8-col)",
|
|
224
|
+
async run() {
|
|
225
|
+
process.stdout.write("\x1b[1;1H\x1b[2K") // Clear line, move to col 1
|
|
226
|
+
process.stdout.write("\t") // Tab
|
|
227
|
+
const pos = await queryCursorPosition()
|
|
228
|
+
if (!pos) return { pass: false, note: "No cursor response" }
|
|
229
|
+
return {
|
|
230
|
+
pass: pos[1] === 9,
|
|
231
|
+
note: pos[1] === 9 ? undefined : `cursor at col ${pos[1]}, expected 9`,
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const backspace: Probe = {
|
|
237
|
+
id: "text.backspace",
|
|
238
|
+
name: "Backspace (BS)",
|
|
239
|
+
async run() {
|
|
240
|
+
process.stdout.write("\x1b[1;5H") // Move to col 5
|
|
241
|
+
process.stdout.write("\b") // BS
|
|
242
|
+
const pos = await queryCursorPosition()
|
|
243
|
+
if (!pos) return { pass: false, note: "No cursor response" }
|
|
244
|
+
return {
|
|
245
|
+
pass: pos[1] === 4,
|
|
246
|
+
note: pos[1] === 4 ? undefined : `cursor at col ${pos[1]}, expected 4`,
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Insert/delete character probes ──
|
|
252
|
+
|
|
253
|
+
const insertChars: Probe = {
|
|
254
|
+
id: "editing.insert-chars",
|
|
255
|
+
name: "Insert characters (ICH)",
|
|
256
|
+
async run() {
|
|
257
|
+
process.stdout.write("\x1b[1;1H\x1b[2K") // Clear line
|
|
258
|
+
process.stdout.write("ABCD")
|
|
259
|
+
process.stdout.write("\x1b[1;2H") // Move to col 2
|
|
260
|
+
process.stdout.write("\x1b[1@") // ICH 1 — insert 1 blank char
|
|
261
|
+
const pos = await queryCursorPosition()
|
|
262
|
+
if (!pos) return { pass: false, note: "No cursor response" }
|
|
263
|
+
// Cursor should remain at col 2 after insert
|
|
264
|
+
return {
|
|
265
|
+
pass: pos[1] === 2,
|
|
266
|
+
note: pos[1] === 2 ? undefined : `cursor at col ${pos[1]}, expected 2`,
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const deleteChars: Probe = {
|
|
272
|
+
id: "editing.delete-chars",
|
|
273
|
+
name: "Delete characters (DCH)",
|
|
274
|
+
async run() {
|
|
275
|
+
process.stdout.write("\x1b[1;1H\x1b[2K") // Clear line
|
|
276
|
+
process.stdout.write("ABCD")
|
|
277
|
+
process.stdout.write("\x1b[1;2H") // Move to col 2
|
|
278
|
+
process.stdout.write("\x1b[1P") // DCH 1 — delete 1 char
|
|
279
|
+
const pos = await queryCursorPosition()
|
|
280
|
+
if (!pos) return { pass: false, note: "No cursor response" }
|
|
281
|
+
// Cursor should remain at col 2 after delete
|
|
282
|
+
return {
|
|
283
|
+
pass: pos[1] === 2,
|
|
284
|
+
note: pos[1] === 2 ? undefined : `cursor at col ${pos[1]}, expected 2`,
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Reset ──
|
|
290
|
+
|
|
291
|
+
const resetRIS: Probe = {
|
|
292
|
+
id: "reset.ris",
|
|
293
|
+
name: "Full reset (RIS)",
|
|
294
|
+
async run() {
|
|
295
|
+
process.stdout.write("\x1b[5;5H") // Move somewhere away from 1;1
|
|
296
|
+
process.stdout.write("\x1bc") // RIS — full reset
|
|
297
|
+
const pos = await queryCursorPosition()
|
|
298
|
+
if (!pos) return { pass: false, note: "No cursor response after RIS" }
|
|
299
|
+
return {
|
|
300
|
+
pass: pos[0] === 1 && pos[1] === 1,
|
|
301
|
+
note: pos[0] === 1 && pos[1] === 1 ? undefined : `cursor at ${pos[0]};${pos[1]}, expected 1;1`,
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Extensions probes ──
|
|
307
|
+
|
|
308
|
+
const kittyKeyboard: Probe = {
|
|
309
|
+
id: "extensions.kitty-keyboard",
|
|
310
|
+
name: "Kitty keyboard protocol",
|
|
311
|
+
async run() {
|
|
312
|
+
// Query current keyboard mode flags — terminal responds with CSI ? flags u
|
|
313
|
+
const match = await query("\x1b[?u", /\x1b\[\?(\d+)u/, 1000)
|
|
314
|
+
if (!match) return { pass: false, note: "No kitty keyboard response" }
|
|
315
|
+
return { pass: true, response: `flags=${match[1]}` }
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const sixelSupport: Probe = {
|
|
320
|
+
id: "extensions.sixel",
|
|
321
|
+
name: "Sixel graphics (DA1 bit 4)",
|
|
322
|
+
async run() {
|
|
323
|
+
const match = await query("\x1b[c", /\x1b\[\?([0-9;]+)c/, 1000)
|
|
324
|
+
if (!match) return { pass: false, note: "No DA1 response" }
|
|
325
|
+
const attrs = match[1]!.split(";")
|
|
326
|
+
const hasSixel = attrs.includes("4")
|
|
327
|
+
return {
|
|
328
|
+
pass: hasSixel,
|
|
329
|
+
note: hasSixel ? undefined : "DA1 response missing ;4 (sixel)",
|
|
330
|
+
response: match[1],
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const osc52Clipboard: Probe = {
|
|
336
|
+
id: "extensions.osc52-clipboard",
|
|
337
|
+
name: "Clipboard query (OSC 52)",
|
|
338
|
+
async run() {
|
|
339
|
+
const match = await query("\x1b]52;c;?\x07", /\x1b\]52;([^\x07\x1b]+)[\x07\x1b]/, 1000)
|
|
340
|
+
if (!match) return { pass: false, note: "No OSC 52 response" }
|
|
341
|
+
return { pass: true, response: match[1] }
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const osc7Cwd: Probe = {
|
|
346
|
+
id: "extensions.osc7-cwd",
|
|
347
|
+
name: "Current directory (OSC 7)",
|
|
348
|
+
async run() {
|
|
349
|
+
// OSC 7 sets the working directory — can't read it back, just verify no crash
|
|
350
|
+
process.stdout.write("\x1b]7;file:///tmp\x07")
|
|
351
|
+
const pos = await queryCursorPosition()
|
|
352
|
+
return { pass: pos !== null }
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── OSC probes ──
|
|
357
|
+
|
|
358
|
+
const osc10FgColor: Probe = {
|
|
359
|
+
id: "extensions.osc10-fg-color",
|
|
360
|
+
name: "Foreground color query (OSC 10)",
|
|
361
|
+
async run() {
|
|
362
|
+
const match = await query("\x1b]10;?\x07", /\x1b\]10;([^\x07\x1b]+)[\x07\x1b]/, 1000)
|
|
363
|
+
if (!match) return { pass: false, note: "No OSC 10 response" }
|
|
364
|
+
return { pass: true, response: match[1] }
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const osc11BgColor: Probe = {
|
|
369
|
+
id: "extensions.osc11-bg-color",
|
|
370
|
+
name: "Background color query (OSC 11)",
|
|
371
|
+
async run() {
|
|
372
|
+
const match = await query("\x1b]11;?\x07", /\x1b\]11;([^\x07\x1b]+)[\x07\x1b]/, 1000)
|
|
373
|
+
if (!match) return { pass: false, note: "No OSC 11 response" }
|
|
374
|
+
return { pass: true, response: match[1] }
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const osc2Title: Probe = {
|
|
379
|
+
id: "extensions.osc2-title",
|
|
380
|
+
name: "Window title (OSC 2)",
|
|
381
|
+
async run() {
|
|
382
|
+
// Set title and verify no crash; can't read it back via PTY
|
|
383
|
+
process.stdout.write("\x1b]2;terminfo-test\x07")
|
|
384
|
+
const pos = await queryCursorPosition()
|
|
385
|
+
process.stdout.write("\x1b]2;\x07") // reset title
|
|
386
|
+
return { pass: pos !== null }
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── All probes ──
|
|
391
|
+
|
|
392
|
+
export const ALL_PROBES: Probe[] = [
|
|
393
|
+
// Cursor
|
|
394
|
+
cursorPositionReport,
|
|
395
|
+
cursorShape,
|
|
396
|
+
cursorSaveRestore,
|
|
397
|
+
|
|
398
|
+
// Device
|
|
399
|
+
primaryDA,
|
|
400
|
+
deviceStatusReport,
|
|
401
|
+
|
|
402
|
+
// Modes via DECRPM
|
|
403
|
+
modeProbe("modes.alt-screen.enter", "Alt screen (DECSET 1049)", 1049),
|
|
404
|
+
modeProbe("modes.bracketed-paste", "Bracketed paste (DECSET 2004)", 2004),
|
|
405
|
+
modeProbe("modes.mouse-tracking", "Mouse tracking (DECSET 1000)", 1000),
|
|
406
|
+
modeProbe("modes.mouse-sgr", "SGR mouse (DECSET 1006)", 1006),
|
|
407
|
+
modeProbe("modes.focus-tracking", "Focus tracking (DECSET 1004)", 1004),
|
|
408
|
+
modeProbe("modes.auto-wrap", "Auto-wrap (DECAWM)", 7),
|
|
409
|
+
modeProbe("modes.application-cursor", "App cursor keys (DECCKM)", 1),
|
|
410
|
+
modeProbe("modes.origin", "Origin mode (DECOM)", 6),
|
|
411
|
+
modeProbe("modes.reverse-video", "Reverse video (DECSCNM)", 5),
|
|
412
|
+
modeProbe("modes.synchronized-output", "Synchronized output (DECSET 2026)", 2026),
|
|
413
|
+
|
|
414
|
+
// Text width
|
|
415
|
+
wideCharCJK,
|
|
416
|
+
wideCharEmoji,
|
|
417
|
+
|
|
418
|
+
// Text behavior
|
|
419
|
+
tabStop,
|
|
420
|
+
backspace,
|
|
421
|
+
|
|
422
|
+
// Erase
|
|
423
|
+
eraseLineRight,
|
|
424
|
+
eraseScreenScrollback,
|
|
425
|
+
|
|
426
|
+
// Editing
|
|
427
|
+
insertChars,
|
|
428
|
+
deleteChars,
|
|
429
|
+
|
|
430
|
+
// Scroll region
|
|
431
|
+
scrollRegion,
|
|
432
|
+
|
|
433
|
+
// Reset
|
|
434
|
+
resetRIS,
|
|
435
|
+
|
|
436
|
+
// SGR (verify sequence is parsed, not printed)
|
|
437
|
+
sgrProbe("sgr.bold", "Bold (SGR 1)", "\x1b[1m"),
|
|
438
|
+
sgrProbe("sgr.faint", "Faint (SGR 2)", "\x1b[2m"),
|
|
439
|
+
sgrProbe("sgr.italic", "Italic (SGR 3)", "\x1b[3m"),
|
|
440
|
+
sgrProbe("sgr.underline.single", "Underline (SGR 4)", "\x1b[4m"),
|
|
441
|
+
sgrProbe("sgr.underline.double", "Double underline (SGR 21)", "\x1b[21m"),
|
|
442
|
+
sgrProbe("sgr.underline.curly", "Curly underline (SGR 4:3)", "\x1b[4:3m"),
|
|
443
|
+
sgrProbe("sgr.underline.dotted", "Dotted underline (SGR 4:4)", "\x1b[4:4m"),
|
|
444
|
+
sgrProbe("sgr.underline.dashed", "Dashed underline (SGR 4:5)", "\x1b[4:5m"),
|
|
445
|
+
sgrProbe("sgr.blink", "Blink (SGR 5)", "\x1b[5m"),
|
|
446
|
+
sgrProbe("sgr.inverse", "Inverse (SGR 7)", "\x1b[7m"),
|
|
447
|
+
sgrProbe("sgr.strikethrough", "Strikethrough (SGR 9)", "\x1b[9m"),
|
|
448
|
+
sgrProbe("sgr.overline", "Overline (SGR 53)", "\x1b[53m"),
|
|
449
|
+
|
|
450
|
+
// Extensions
|
|
451
|
+
kittyKeyboard,
|
|
452
|
+
sixelSupport,
|
|
453
|
+
osc52Clipboard,
|
|
454
|
+
osc7Cwd,
|
|
455
|
+
|
|
456
|
+
// OSC
|
|
457
|
+
osc10FgColor,
|
|
458
|
+
osc11BgColor,
|
|
459
|
+
osc2Title,
|
|
460
|
+
]
|
package/src/submit.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Submit results to terminfo.dev via GitHub issue.
|
|
3
|
+
*
|
|
4
|
+
* Creates an issue on beorn/terminfo.dev with the JSON results
|
|
5
|
+
* attached as a code block. Maintainers review and merge into
|
|
6
|
+
* the results database.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const REPO = "beorn/terminfo.dev"
|
|
10
|
+
const ISSUE_LABEL = "community-results"
|
|
11
|
+
|
|
12
|
+
interface SubmitResult {
|
|
13
|
+
terminal: string
|
|
14
|
+
terminalVersion: string
|
|
15
|
+
os: string
|
|
16
|
+
osVersion: string
|
|
17
|
+
results: Record<string, boolean>
|
|
18
|
+
notes: Record<string, string>
|
|
19
|
+
responses: Record<string, string>
|
|
20
|
+
generated: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Submit results by creating a GitHub issue.
|
|
25
|
+
* Requires `gh` CLI to be installed and authenticated.
|
|
26
|
+
*/
|
|
27
|
+
export async function submitResults(data: SubmitResult): Promise<string | null> {
|
|
28
|
+
const passed = Object.values(data.results).filter(Boolean).length
|
|
29
|
+
const total = Object.keys(data.results).length
|
|
30
|
+
const pct = Math.round((passed / total) * 100)
|
|
31
|
+
|
|
32
|
+
const title = `[census] ${data.terminal}${data.terminalVersion ? ` ${data.terminalVersion}` : ""} on ${data.os} — ${pct}% (${passed}/${total})`
|
|
33
|
+
|
|
34
|
+
const body = `## Community Census Result
|
|
35
|
+
|
|
36
|
+
| Field | Value |
|
|
37
|
+
|-------|-------|
|
|
38
|
+
| Terminal | ${data.terminal} |
|
|
39
|
+
| Version | ${data.terminalVersion || "unknown"} |
|
|
40
|
+
| OS | ${data.os} ${data.osVersion || ""} |
|
|
41
|
+
| Score | ${passed}/${total} (${pct}%) |
|
|
42
|
+
| Generated | ${data.generated} |
|
|
43
|
+
|
|
44
|
+
### Results
|
|
45
|
+
|
|
46
|
+
<details>
|
|
47
|
+
<summary>Full JSON (click to expand)</summary>
|
|
48
|
+
|
|
49
|
+
\`\`\`json
|
|
50
|
+
${JSON.stringify(data, null, 2)}
|
|
51
|
+
\`\`\`
|
|
52
|
+
|
|
53
|
+
</details>
|
|
54
|
+
|
|
55
|
+
### Summary
|
|
56
|
+
|
|
57
|
+
${formatSummary(data)}
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
*Submitted via \`npx terminfo\`*`
|
|
61
|
+
|
|
62
|
+
// Try gh CLI first
|
|
63
|
+
if (await hasGhCli()) {
|
|
64
|
+
try {
|
|
65
|
+
const { execSync } = await import("node:child_process")
|
|
66
|
+
const result = execSync(
|
|
67
|
+
`gh issue create --repo ${REPO} --title ${JSON.stringify(title)} --body ${JSON.stringify(body)} --label ${ISSUE_LABEL}`,
|
|
68
|
+
{ encoding: "utf-8", timeout: 30000 },
|
|
69
|
+
)
|
|
70
|
+
const url = result.trim()
|
|
71
|
+
return url
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(`\x1b[31mFailed to create issue via gh CLI\x1b[0m`)
|
|
74
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fallback: write to file and give instructions
|
|
80
|
+
const { writeFileSync } = await import("node:fs")
|
|
81
|
+
const filename = `terminfo-${data.terminal}-${data.os}-${Date.now()}.json`
|
|
82
|
+
writeFileSync(filename, JSON.stringify(data, null, 2))
|
|
83
|
+
console.log(`\n\x1b[33mgh CLI not found. Results saved to ${filename}\x1b[0m`)
|
|
84
|
+
console.log(`To submit manually:`)
|
|
85
|
+
console.log(` 1. Go to https://github.com/${REPO}/issues/new`)
|
|
86
|
+
console.log(` 2. Title: ${title}`)
|
|
87
|
+
console.log(` 3. Paste the contents of ${filename}`)
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function hasGhCli(): Promise<boolean> {
|
|
92
|
+
try {
|
|
93
|
+
const { execSync } = await import("node:child_process")
|
|
94
|
+
execSync("gh --version", { stdio: "ignore", timeout: 5000 })
|
|
95
|
+
return true
|
|
96
|
+
} catch {
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatSummary(data: SubmitResult): string {
|
|
102
|
+
const categories = new Map<string, { pass: number; fail: number; failList: string[] }>()
|
|
103
|
+
|
|
104
|
+
for (const [id, pass] of Object.entries(data.results)) {
|
|
105
|
+
const cat = id.split(".")[0]!
|
|
106
|
+
if (!categories.has(cat)) categories.set(cat, { pass: 0, fail: 0, failList: [] })
|
|
107
|
+
const entry = categories.get(cat)!
|
|
108
|
+
if (pass) entry.pass++
|
|
109
|
+
else {
|
|
110
|
+
entry.fail++
|
|
111
|
+
const note = data.notes[id]
|
|
112
|
+
entry.failList.push(note ? `- \`${id}\`: ${note}` : `- \`${id}\``)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const lines: string[] = []
|
|
117
|
+
for (const [cat, { pass, fail, failList }] of categories) {
|
|
118
|
+
const total = pass + fail
|
|
119
|
+
const icon = fail === 0 ? "✅" : "⚠️"
|
|
120
|
+
lines.push(`${icon} **${cat}**: ${pass}/${total}`)
|
|
121
|
+
if (failList.length > 0) lines.push(...failList)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return lines.join("\n")
|
|
125
|
+
}
|
package/src/tty.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTY utilities — raw mode, response reading, escape sequence I/O.
|
|
3
|
+
*
|
|
4
|
+
* The core primitive: write an escape sequence to stdout, read a response
|
|
5
|
+
* from stdin within a timeout.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read a response matching a pattern from stdin within a timeout.
|
|
10
|
+
* Must be called while stdin is in raw mode.
|
|
11
|
+
*/
|
|
12
|
+
export function readResponse(pattern: RegExp, timeoutMs: number): Promise<string[] | null> {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
let buf = ""
|
|
15
|
+
let timer: ReturnType<typeof setTimeout>
|
|
16
|
+
|
|
17
|
+
const onData = (chunk: Buffer) => {
|
|
18
|
+
buf += chunk.toString()
|
|
19
|
+
const match = buf.match(pattern)
|
|
20
|
+
if (match) {
|
|
21
|
+
cleanup()
|
|
22
|
+
resolve([...match])
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cleanup = () => {
|
|
27
|
+
clearTimeout(timer)
|
|
28
|
+
process.stdin.off("data", onData)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
timer = setTimeout(() => {
|
|
32
|
+
cleanup()
|
|
33
|
+
resolve(null)
|
|
34
|
+
}, timeoutMs)
|
|
35
|
+
|
|
36
|
+
process.stdin.on("data", onData)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Send an escape sequence and read the response.
|
|
42
|
+
* Handles raw mode setup/teardown.
|
|
43
|
+
*/
|
|
44
|
+
export async function query(sequence: string, responsePattern: RegExp, timeoutMs = 1000): Promise<string[] | null> {
|
|
45
|
+
process.stdout.write(sequence)
|
|
46
|
+
return readResponse(responsePattern, timeoutMs)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Query cursor position via DSR 6 (Device Status Report).
|
|
51
|
+
* Returns [row, col] (1-based) or null if no response.
|
|
52
|
+
*/
|
|
53
|
+
export async function queryCursorPosition(): Promise<[number, number] | null> {
|
|
54
|
+
const match = await query("\x1b[6n", /\x1b\[(\d+);(\d+)R/)
|
|
55
|
+
if (!match) return null
|
|
56
|
+
return [parseInt(match[1]!, 10), parseInt(match[2]!, 10)]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write text, then query cursor position to determine rendered width.
|
|
61
|
+
*/
|
|
62
|
+
export async function measureRenderedWidth(text: string): Promise<number | null> {
|
|
63
|
+
// Save cursor, move to col 1, write text, query position
|
|
64
|
+
process.stdout.write("\x1b7\x1b[1G" + text)
|
|
65
|
+
const pos = await queryCursorPosition()
|
|
66
|
+
// Restore cursor
|
|
67
|
+
process.stdout.write("\x1b8")
|
|
68
|
+
if (!pos) return null
|
|
69
|
+
return pos[1] - 1 // col is 1-based, width is 0-based
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Query whether a DEC private mode is recognized via DECRPM.
|
|
74
|
+
* Returns "set", "reset", "unknown", or null (no response).
|
|
75
|
+
*/
|
|
76
|
+
export async function queryMode(modeNumber: number): Promise<"set" | "reset" | "unknown" | null> {
|
|
77
|
+
const match = await query(`\x1b[?${modeNumber}$p`, /\x1b\[\?(\d+);(\d+)\$y/)
|
|
78
|
+
if (!match) return null
|
|
79
|
+
const status = parseInt(match[2]!, 10)
|
|
80
|
+
switch (status) {
|
|
81
|
+
case 1:
|
|
82
|
+
return "set"
|
|
83
|
+
case 2:
|
|
84
|
+
return "reset"
|
|
85
|
+
case 0:
|
|
86
|
+
return "unknown"
|
|
87
|
+
default:
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run a function with stdin in raw mode.
|
|
94
|
+
* Restores original mode on exit.
|
|
95
|
+
*/
|
|
96
|
+
export async function withRawMode<T>(fn: () => Promise<T>): Promise<T> {
|
|
97
|
+
const wasRaw = process.stdin.isRaw
|
|
98
|
+
if (process.stdin.isTTY) {
|
|
99
|
+
process.stdin.setRawMode(true)
|
|
100
|
+
process.stdin.resume()
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return await fn()
|
|
104
|
+
} finally {
|
|
105
|
+
if (process.stdin.isTTY) {
|
|
106
|
+
process.stdin.setRawMode(wasRaw ?? false)
|
|
107
|
+
process.stdin.pause()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|