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 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
+ }