ttyscan 0.0.2__py3-none-any.whl

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.
@@ -0,0 +1,263 @@
1
+ Metadata-Version: 2.4
2
+ Name: ttyscan
3
+ Version: 0.0.2
4
+ Summary: Queries a terminal emulator for its type, size and capabilities
5
+ Project-URL: Homepage, https://github.com/jquast/ttyscan
6
+ Author-email: Jeff Quast <contact@jeffquast.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: capability,colorterm,console,curses,query,serial,setupterm,ssh,telnet,terminal,terminfo,tty,xtgettcap
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: English
14
+ Classifier: Operating System :: POSIX
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Software Development :: Libraries
24
+ Classifier: Topic :: Terminals
25
+ Requires-Python: >=3.8
26
+ Description-Content-Type: text/x-rst
27
+
28
+ | |pypi_downloads| |codecov| |windows| |linux| |mac| |bsd|
29
+
30
+ ttyscan
31
+ =======
32
+
33
+ *ttyscan* queries a terminal emulator for its type, size and capabilities, creating a
34
+ curses-compatible `terminfo(5)`_ file entry if necessary, and exports any corrected values of
35
+ ``TERM``, ``COLORTERM``, ``LINES``, ``COLUMNS``, and optionally ``TERMCAP`` environment variables.
36
+
37
+ Problem
38
+ -------
39
+
40
+ ncurses_ does not support XTGETTCAP, and so `terminfo(5)`_ files for some terminals must be
41
+ deployed to remote systems by system operators in some circumstances.
42
+
43
+ Curses programs fail to start until those files are deployed or an alternate ``TERM`` is exported.
44
+ All kinds of errors and warnings may be raised until this is done, some examples::
45
+
46
+ $ tmux attach
47
+ missing or unsuitable terminal: xterm-kitty
48
+
49
+ $ irssi
50
+ setupterm() failed for TERM=xterm-kitty: 0
51
+ Can't initialize screen handling.
52
+
53
+ $ htop
54
+ Error opening terminal: xterm-kitty.
55
+
56
+ $ python -c 'import curses;curses.setupterm()'
57
+ Traceback (most recent call last):
58
+ File "<string>", line 1, in <module>
59
+ _curses.error: setupterm: could not find terminal
60
+
61
+ $ git log -p
62
+ WARNING: terminal is not fully functional
63
+ Press RETURN to continue
64
+
65
+ Solution
66
+ --------
67
+
68
+ *ttyscan* creates the missing `terminfo(5)` file using the ``XTGETTCAP`` (``DCS +q``) terminal query
69
+ protocol when supported, and suggests a new ``TERMINFO`` environment value for export, allowing
70
+ legacy calls to curses of `setupterm(3)`_ to succeed::
71
+
72
+ $ ttyscan -vft
73
+ ttyscan: probing terminal via XTGETTCAP ...
74
+ ttyscan: size via dual CPR: 28x100
75
+ ttyscan: XTGETTCAP supported, terminal: rio; received 204 caps (10 bool, 5 num, 185 str) in 1523ms
76
+ ttyscan: writing /home/jq/.config/ttyscan/terminfo/r/rio
77
+ export COLORTERM=truecolor
78
+ export TERM=rio
79
+ export LINES=28
80
+ export COLUMNS=100
81
+ export TERMINFO=/home/jq/.config/ttyscan/terminfo
82
+ export TERMCAP='rio|XTGETTCAP-discovered terminal::am::ut::hs::km::5i::mi::ms::NP::xn::Co#256::co#80::it#8::li#24::pa#32767::ac=``aaffggiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz{{||}}~~::bl=^G::mb=\E[5m::md=\E[1m::vi=\E[?25l::cl=\E[H\E[2J::ve=\E[?12l\E[?25h::cr=\r::cs=\E[%i%p1%d;%p2%dr::le=\b::DO=\E[%p1%dB::do=\n::nd=\E[C::cm=\E[%i%p1%d;%p2%dH::up=\E[A::vs=\E[?12;25h::dC=\E[P::mh=\E[2m::dl=\E[M::ds=\E]2;^G::ec=\E[%p1%dX::cd=\E[J::ce=\E[K::cb=\E[1K::vb=\E[?5h$<100/>\E[?5l::fs=^G::ho=\E[H::ch=\E[%i%p1%dG::ta=\t::st=\EH::al=\E[L::sf=\n::mk=\E[8m::is=\E[!p\E[?3;4l\E[4l\E>::K2=\EOE::kb=^?::kB=\E[Z::kl=\EOD::kd=\EOB::kr=\EOC::ku=\EOA::kD=\E[3~::@7=\EOF::@8=\EOM::k1=\EOP::k0=\E[21~::F1=\E[23~::F2=\E[24~::k2=\EOQ::k3=\EOR::k4=\EOS::k5=\E[15~::k6=\E[17~::k7=\E[18~::k8=\E[19~::k9=\E[20~::kh=\EOH::kI=\E[2~::kH=\E[1;2B::kN=\E[6~::kP=\E[5~::op=\E[39;49m::rp=%p1%c\E[%p2%{1}%-%db::mr=\E[7m::sr=\EM::ZR=\E[23m::ae=\E(B::te=\E[?1049l\E[23;0;0t::ei=\E[4l::ke=\E[?1l\E>::se=\E[27m::ue=\E[24m::r1=\Ec\E]104^G::r2=\E[!p\E[?3;4l\E[4l\E>::sa=%?%p9%t\E(0%e\E(B%;\E[0%?%p6%t;1%;%?%p5%t;2%;%?%p2%t;4%;%?%p1%p3%|%t;7%;%?%p4%t;5%;%?%p7%t;8%;m::me=\E(B\E[m::ZH=\E[3m::as=\E(0::ti=\E[?1049h\E[22;0;0t::im=\E[4h::ks=\E[?1h\E=::so=\E[7m::us=\E[4m::ct=\E[3g::ts=\E]2;::cv=\E[%i%p1%dd:'
83
+
84
+ $ eval `ttyscan`
85
+
86
+ $ echo $TERM, $TERMINFO
87
+ rio, /home/jquast/.config/ttyscan/terminfo
88
+
89
+ $ file -b /home/jquast/.config/ttyscan/terminfo/r/rio
90
+ Compiled terminfo entry "rio"
91
+
92
+ $ python -c 'import curses;curses.setupterm()'; echo $?
93
+ 0
94
+
95
+ CLI Arguments
96
+ -------------
97
+
98
+ ::
99
+
100
+ usage: ttyscan [-h] [-v] [-f] [-t]
101
+
102
+ Export terminal capabilities discovered via XTGETTCAP
103
+
104
+ options:
105
+ -h, --help show this help message and exit
106
+ -v, --verbose Print diagnostic information to stderr
107
+ -f, --force Force export of all values even if unchanged
108
+ -t, --termcap Also export TERMCAP value
109
+
110
+ *ttyscan* saves a `terminfo(5)`_ file to $XDG_CONFIG_HOME/ttyscan/terminfo or
111
+ ~/.config/ttyscan/terminfo, and does not re-query or re-create it when it already exists from
112
+ previous executions, unless ``--force`` argument is used.
113
+
114
+ Typical total execution time is 200ms. The default timeout query can be changed by environment
115
+ value TTYSCAN_QUERY_TIMEOUT=1.0 (float), in seconds. If timeout is reached, terminal response may
116
+ "bleed" into subsequent programs, (like a shell prompt).
117
+
118
+ Installation
119
+ ------------
120
+
121
+ .. code-block:: shell
122
+
123
+ pip install ttyscan
124
+
125
+ *ttyscan* requires Python3.8+.
126
+
127
+ ttyscan.py_ is a stand-alone python file, it does not require pip to install, you can copy this
128
+ single file directly from source and execute it from source, eg. ``python ~/bin/ttyscan.py``
129
+
130
+ Motive
131
+ ------
132
+
133
+ Naturally, transferring your ``$HOME/.terminfo`` folder to a remote machine is the best solution.
134
+
135
+ However, enterprise systems, bastion hosts, cloud systems, web consoles, hypervisors, radio, and
136
+ serial ports can be challenging places or protocol layers through which to deploy terminfo files.
137
+
138
+ Some workarounds include exporting a generally-compatible ``TERM=xterm-256color`` or ``xterm`` with
139
+ sometimes minor corruption of screen output, missed interpretation of keyboard input such as
140
+ backspace or delete, or a small reduction of features such as italic or underlined text or number of
141
+ colors.
142
+
143
+ **And so this tool is not often needed** except for the **very serious** terminal connoisseur.
144
+
145
+ It serves as a demonstration: that a full terminal capability database *can* be transferred using
146
+ XTGETTCAP_ for many modern terminals, supporting modern `terminfo(5)`_ and legacy `termcap(5)`_. It
147
+
148
+ My hope is that `setupterm(3)`_ may negotiate with full XTGETTCAP_ support at some point in the
149
+ future, and that this utility is not commonly used or required!
150
+
151
+ Scope
152
+ -----
153
+
154
+ At time of this writing (May 2026), the ucs-detect_ dataset of 42 terminals shows three categories
155
+ of support for ``XTGETTCAP``:
156
+
157
+ - **Full** ``XTGETTCAP`` capability support: contour_, foot_, ghostty_, kitty_, rio, and wezterm
158
+ transmit their complete terminfo boolean, numeric, and string capabilities via XTGETTCAP_.
159
+
160
+ *ttyscan* produces terminfo files for only these terminals. *ttyscan* may also discover a
161
+ preferred ``TERM`` from ``TN``, and ``COLORTERM=truecolor`` from ``RGB``.
162
+
163
+ - **Partial** ``XTGETTCAP`` capability support: XTerm_, iterm2, mlterm, AbsoluteTelnet/SSH, GNOME
164
+ Terminal, LXTerminal, terminator, termit, and xfce4-terminal report only ``TN``, ``Co``, and
165
+ ``RGB``.
166
+
167
+ *ttyscan* may only discover preferred ``TERM`` from ``TN``, and ``COLORTERM=truecolor`` from
168
+ ``RGB``.
169
+
170
+ XTerm_ supports only keyboard sequences in addition to ``TN``, ``Co``, and ``RGB``. It does not
171
+ report *all* capabilities, and so a `terminfo(5)`_ entry cannot be built. However,
172
+ ``TERM=xterm-256color`` and ``TERM=xterm`` are the most ubiquitous terminal name, you should be
173
+ just fine.
174
+
175
+ - **None**: alacritty (refuses: `alacritty/vte#98`_), bobcat, cmd.exe, ConEmu, cool-retro-term,
176
+ Extraterm, Hyper, Konsole (requested: `KDE#507017`_), linux fbdev, mintty, PuTTY, QTerminal,
177
+ rxvt-unicode, screen, securecrt, st, Tabby, Apple Terminal, Terminal.exe (planned:
178
+ `microsoft/terminal#17735`_), terminology, tmux (passthrough: `tmux/tmux#3755`_), libvterm, VS
179
+ Code (xterm.js, proposal: `xtermjs/xterm.js#4107`_), weston-terminal, and zutty.
180
+
181
+ Architecture
182
+ ------------
183
+
184
+ *ttyscan* uses the following,
185
+
186
+ - ``XTGETTCAP`` field ``TN`` is used to correct ``TERM`` when unmatched.
187
+ - ``XTGETTCAP`` field ``RGB`` is used to correct ``COLORTERM`` when unmatched.
188
+ - all capability strings, keyboard and screen, when provided by ``XTGETTCAP``.
189
+ - DEC Private Mode 2048 (In-Band Resize) to determine the window size
190
+ - Or failing that, using Cursor Position Report sequence like done in XTerm's `resize.c
191
+ <https://github.com/joejulian/xterm/blob/master/resize.c>`_
192
+
193
+ Details
194
+ -------
195
+
196
+ The difference of *Full* and *Partial* ``XTGETTCAP`` support is best described by foot_:
197
+
198
+ ``XTGETTCAP`` is an escape sequence initially introduced by XTerm_, and also implemented (and extended,
199
+ to some degree) by Kitty.
200
+
201
+ Applications using this feature do not need to use the classic, file-based, terminfo definition.
202
+
203
+ XTerm's implementation (as of XTerm-370) only supports querying key (as in keyboard keys)
204
+ capabilities, and three custom capabilities:
205
+
206
+ ``TN`` - terminal name
207
+ ``Co`` - number of colors (alias for the colors capability)
208
+ ``RGB`` - number of bits per color channel (different semantics from the RGB capability in file-based
209
+ terminfo definitions!).
210
+
211
+ Kitty has extended this, and also supports querying all integer and string capabilities.
212
+
213
+ Foot supports this, and extends it even further, to also include boolean capabilities.
214
+ This means foot's entire terminfo can be queried via ``XTGETTCAP``.
215
+
216
+ Further, all of ``TERM``, ``COLORTERM``, ``LINES``, or ``COLUMNS`` may not be transmitted by all
217
+ software or protocols, some examples:
218
+
219
+ - ssh does not forward ``COLORTERM`` unless configured using ``SendEnv`` in `ssh_config(5)`_ and
220
+ ``AcceptEnv`` in `sshd_config(5)`_.
221
+ - serial does not forward any; ``TERM`` is defined by host `agetty(8)`_ configuration, for example.
222
+ - rlogin can forward all but ``COLORTERM``.
223
+ - telnet can forward all, but IAC NAWS and NEW-ENVIRON capability varies by software.
224
+ - websocket cannot forward any without customization, often used in-browser.
225
+
226
+ *ttyscan* detects when these variables are unset or do not match values and re-exports the corrected
227
+ values when they differ.
228
+
229
+ .. _`agetty(8)`: https://linux.die.net/man/8/agetty
230
+ .. _`alacritty/vte#98`: https://github.com/alacritty/vte/issues/98
231
+ .. _contour: https://github.com/contour-terminal/contour
232
+ .. _foot: https://codeberg.org/dnkl/foot#xtgettcap
233
+ .. _ghostty: https://mitchellh.com/writing/ghostty-devlog-004
234
+ .. _`KDE#507017`: https://bugs.kde.org/show_bug.cgi?id=507017
235
+ .. _kitty: https://sw.kovidgoyal.net/kitty/kittens/query_terminal/
236
+ .. _`microsoft/terminal#17735`: https://github.com/microsoft/terminal/issues/17735
237
+ .. _ncurses: https://invisible-island.net/ncurses/
238
+ .. _`setupterm(3)`: https://linux.die.net/man/3/setupterm
239
+ .. _`ssh_config(5)`: https://linux.die.net/man/5/ssh_config
240
+ .. _`sshd_config(5)`: https://linux.die.net/man/5/sshd_config
241
+ .. _`termcap(5)`: https://linux.die.net/man/5/termcap
242
+ .. _`terminfo(5)`: https://linux.die.net/man/5/terminfo
243
+ .. _`tmux/tmux#3755`: https://github.com/tmux/tmux/issues/3755
244
+ .. _`ttyscan.py`: https://github.com/jquast/ttyscan/blob/master/ttyscan.py
245
+ .. _ucs-detect: https://ucs-detect.readthedocs.io/results.html#terminal-capabilities
246
+ .. _XTerm: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Device-Control-functions:DCS-plus-q-Pt-ST.F95
247
+ .. _`xtermjs/xterm.js#4107`: https://github.com/xtermjs/xterm.js/issues/4107
248
+ .. _XTGETTCAP: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Device-Control-functions:DCS-plus-q-Pt-ST.F95
249
+
250
+ .. |pypi_downloads| image:: https://img.shields.io/pypi/dm/ttyscan.svg?logo=pypi
251
+ :alt: Downloads
252
+ :target: https://pypi.org/project/ttyscan/
253
+ .. |codecov| image:: https://codecov.io/gh/jquast/ttyscan/branch/master/graph/badge.svg
254
+ :alt: codecov.io Code Coverage
255
+ :target: https://codecov.io/gh/jquast/ttyscan/
256
+ .. |linux| image:: https://img.shields.io/badge/Linux-yes-success?logo=linux
257
+ :alt: Linux supported
258
+ .. |windows| image:: https://img.shields.io/badge/Windows-yes-success?logo=windows
259
+ :alt: Windows supported
260
+ .. |mac| image:: https://img.shields.io/badge/MacOS-yes-success?logo=apple
261
+ :alt: MacOS supported
262
+ .. |bsd| image:: https://img.shields.io/badge/BSD-yes-success?logo=freebsd
263
+ :alt: BSD supported
@@ -0,0 +1,6 @@
1
+ ttyscan.py,sha256=YAtuy0ARA1_KCmm9WjAdbnouUaATqOjPy0xyTIDRVRE,27884
2
+ ttyscan-0.0.2.dist-info/METADATA,sha256=YeDQyD7YfzjhSMP_GoOEE9LZu6Tkswr254fRxpPRqjc,12768
3
+ ttyscan-0.0.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
4
+ ttyscan-0.0.2.dist-info/entry_points.txt,sha256=D3_mvwKz7kJJ4cTivulSnFf_njTLHNuoAEFyUeIOTs8,41
5
+ ttyscan-0.0.2.dist-info/licenses/LICENSE,sha256=s66usgtPL2otKLK6u_6HLrUKkIz4mEMUNkNf3-uItds,1067
6
+ ttyscan-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ttyscan = ttyscan:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeff Quast
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ttyscan.py ADDED
@@ -0,0 +1,805 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ import array
5
+ import os
6
+ import re
7
+ import select
8
+ import struct
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+ try:
14
+ import fcntl
15
+ import termios
16
+ import tty
17
+ except ImportError as exc:
18
+ sys.exit(f"ttyscan: unsupported platform (missing required module: {exc})")
19
+
20
+ __version__ = "0.0.2"
21
+
22
+
23
+ def warn(msg):
24
+ print(f"ttyscan: {msg}", file=sys.stderr)
25
+
26
+
27
+ try:
28
+ _TTYSCAN_QUERY_TIMEOUT = float(os.environ.get('TTYSCAN_QUERY_TIMEOUT', '1.0'))
29
+ except ValueError:
30
+ warn(f"TTYSCAN_QUERY_TIMEOUT value "
31
+ f"{os.environ['TTYSCAN_QUERY_TIMEOUT']!r} "
32
+ f"is not a valid float, using default 1.0")
33
+ _TTYSCAN_QUERY_TIMEOUT = 1.0
34
+
35
+ _CANONICAL_BOOL_CAPS = [
36
+ "bw", "am", "xsb", "xhp", "xenl", "eo", "gn", "hc", "km", "hs",
37
+ "in", "da", "db", "mir", "msgr", "os", "eslok", "xt", "hz", "ul",
38
+ "xon", "nxon", "mc5i", "chts", "nrrmc", "npc", "ndscr", "ccc", "bce",
39
+ "hls", "xhpa", "crxm", "daisy", "xvpa", "sam", "cpix", "lpix",
40
+ "OTbs", "OTns", "OTnc", "OTMT", "OTNL", "OTpt", "OTxr",
41
+ ]
42
+
43
+ _CANONICAL_NUM_CAPS = [
44
+ "cols", "it", "lines", "lm", "xmc", "pb", "vt", "wsl", "nlab",
45
+ "lh", "lw", "ma", "wnum", "colors", "pairs", "ncv", "bufsz",
46
+ "spinv", "spinh", "maddr", "mjump", "mcs", "mls", "npins",
47
+ "orc", "orl", "orhi", "orvi", "cps", "widcs", "btns",
48
+ "bitwin", "bitype", "OTug", "OTdC", "OTdN", "OTdB", "OTdT", "OTkn",
49
+ ]
50
+
51
+ _CANONICAL_STR_CAPS = [
52
+ "cbt", "bel", "cr", "csr", "tbc", "clear", "el", "ed", "hpa",
53
+ "cmdch", "cup", "cud1", "home", "civis", "cub1", "mrcup", "cnorm",
54
+ "cuf1", "ll", "cuu1", "cvvis", "dch1", "dl1", "dsl", "hd", "smacs",
55
+ "blink", "bold", "smcup", "smdc", "dim", "smir", "invis", "prot",
56
+ "rev", "smso", "smul", "ech", "rmacs", "sgr0", "rmcup", "rmdc",
57
+ "rmir", "rmso", "rmul", "flash", "ff", "fsl", "is1", "is2", "is3",
58
+ "if", "ich1", "il1", "ip", "kbs", "ktbc", "kclr", "kctab", "kdch1",
59
+ "kdl1", "kcud1", "krmir", "kel", "ked", "kf0", "kf1", "kf10", "kf2",
60
+ "kf3", "kf4", "kf5", "kf6", "kf7", "kf8", "kf9", "khome", "kich1",
61
+ "kil1", "kcub1", "kll", "knp", "kpp", "kcuf1", "kind", "kri", "khts",
62
+ "kcuu1", "rmkx", "smkx", "lf0", "lf1", "lf10", "lf2", "lf3", "lf4",
63
+ "lf5", "lf6", "lf7", "lf8", "lf9", "rmm", "smm", "nel", "pad", "dch",
64
+ "dl", "cud", "ich", "indn", "il", "cub", "cuf", "rin", "cuu", "pfkey",
65
+ "pfloc", "pfx", "mc0", "mc4", "mc5", "rep", "rs1", "rs2", "rs3", "rf",
66
+ "rc", "vpa", "sc", "ind", "ri", "sgr", "hts", "wind", "ht", "tsl",
67
+ "uc", "hu", "iprog", "ka1", "ka3", "kb2", "kc1", "kc3", "mc5p", "rmp",
68
+ "acsc", "pln", "kcbt", "smxon", "rmxon", "smam", "rmam", "xonc",
69
+ "xoffc", "enacs", "smln", "rmln", "kbeg", "kcan", "kclo", "kcmd",
70
+ "kcpy", "kcrt", "kend", "kent", "kext", "kfnd", "khlp", "kmrk",
71
+ "kmsg", "kmov", "knxt", "kopn", "kopt", "kprv", "kprt", "krdo",
72
+ "kref", "krfr", "krpl", "krst", "kres", "ksav", "kspd", "kund",
73
+ "kBEG", "kCAN", "kCMD", "kCPY", "kCRT", "kDC", "kDL", "kslt", "kEND",
74
+ "kEOL", "kEXT", "kFND", "kHLP", "kHOM", "kIC", "kLFT", "kMSG", "kMOV",
75
+ "kNXT", "kOPT", "kPRV", "kPRT", "kRDO", "kRPL", "kRIT", "kRES",
76
+ "kSAV", "kSPD", "kUND", "rfi", "kf11", "kf12", "kf13", "kf14", "kf15",
77
+ "kf16", "kf17", "kf18", "kf19", "kf20", "kf21", "kf22", "kf23",
78
+ "kf24", "kf25", "kf26", "kf27", "kf28", "kf29", "kf30", "kf31",
79
+ "kf32", "kf33", "kf34", "kf35", "kf36", "kf37", "kf38", "kf39",
80
+ "kf40", "kf41", "kf42", "kf43", "kf44", "kf45", "kf46", "kf47",
81
+ "kf48", "kf49", "kf50", "kf51", "kf52", "kf53", "kf54", "kf55",
82
+ "kf56", "kf57", "kf58", "kf59", "kf60", "kf61", "kf62", "kf63",
83
+ "el1", "mgc", "smgl", "smgr", "fln", "sclk", "dclk", "rmclk", "cwin",
84
+ "wingo", "hup", "dial", "qdial", "tone", "pulse", "hook", "pause",
85
+ "wait", "u0", "u1", "u2", "u3", "u4", "u5", "u6", "u7", "u8", "u9",
86
+ "op", "oc", "initc", "initp", "scp", "setf", "setb", "cpi", "lpi",
87
+ "chr", "cvr", "defc", "swidm", "sdrfq", "sitm", "slm", "smicm",
88
+ "snlq", "snrmq", "sshm", "ssubm", "ssupm", "sum", "rwidm", "ritm",
89
+ "rlm", "rmicm", "rshm", "rsubm", "rsupm", "rum", "mhpa", "mcud1",
90
+ "mcub1", "mcuf1", "mvpa", "mcuu1", "porder", "mcud", "mcub", "mcuf",
91
+ "mcuu", "scs", "smgb", "smgbp", "smglp", "smgrp", "smgt", "smgtp",
92
+ "sbim", "scsd", "rbim", "rcsd", "subcs", "supcs", "docr", "zerom",
93
+ "csnm", "kmous", "minfo", "reqmp", "getm", "setaf", "setab", "pfxl",
94
+ "devt", "csin", "s0ds", "s1ds", "s2ds", "s3ds", "smglr", "smgtb",
95
+ "birep", "binel", "bicr", "colornm", "defbi", "endbi", "setcolor",
96
+ "slines", "dispc", "smpch", "rmpch", "smsc", "rmsc", "pctrm", "scesc",
97
+ "scesa", "ehhlm", "elhlm", "elohlm", "erhlm", "ethlm", "evhlm",
98
+ "sgr1", "slength", "OTi2", "OTrs", "OTnl", "OTbc", "OTko", "OTma",
99
+ "OTG2", "OTG3", "OTG1", "OTG4", "OTGR", "OTGL", "OTGU", "OTGD",
100
+ "OTGH", "OTGV", "OTGC", "meml", "memu", "box1",
101
+ ]
102
+
103
+ _INIT_XTGETTCAP_CAPS = frozenset((
104
+ 'TN', 'RGB', 'colors', 'blink', 'sitm', 'ritm', 'cvvis', 'Smulx', 'Setulc', 'Ms'
105
+ ))
106
+
107
+ _FULL_XTGETTCAP_CAPS = tuple(
108
+ sorted(set(_CANONICAL_BOOL_CAPS) | set(_CANONICAL_NUM_CAPS) | set(_CANONICAL_STR_CAPS))
109
+ )
110
+
111
+ _BOOL_SET = frozenset(_CANONICAL_BOOL_CAPS)
112
+ _NUM_SET = frozenset(_CANONICAL_NUM_CAPS)
113
+ _STR_SET = frozenset(_CANONICAL_STR_CAPS)
114
+
115
+ _RE_XTGETTCAP_RESPONSE = re.compile(
116
+ r'\x1bP([01])\+r([0-9a-fA-F]+)(?:=([0-9a-fA-F]*))?\x1b\\')
117
+ _RE_CPR = re.compile(r'\x1b\[(\d+);(\d+)R')
118
+ _RE_CPR_BOUNDARY = re.compile(r'\x1b\[[0-9]+;[0-9]+R')
119
+ _RE_DECRPM = re.compile(r'\x1b\[\?(\d+);([0-4])\$y')
120
+ _RE_RESIZE = re.compile(r'\x1b\[48;(\d+);(\d+);(\d+);(\d+)t')
121
+
122
+ _TERMINFO_MAGIC = 0o432
123
+ _TERMINFO_MAGIC2 = 0o1036
124
+ _SENTINEL_ABSENT = -1
125
+
126
+ _BOOL_INDEX = {name: idx for idx, name in enumerate(_CANONICAL_BOOL_CAPS)}
127
+ _NUM_INDEX = {name: idx for idx, name in enumerate(_CANONICAL_NUM_CAPS)}
128
+ _STR_INDEX = {name: idx for idx, name in enumerate(_CANONICAL_STR_CAPS)}
129
+
130
+ _TERMINFO_ESCAPE = {
131
+ 'E': '\x1b', 'e': '\x1b',
132
+ 'n': '\n', 't': '\t', 'r': '\r',
133
+ 'b': '\b', 'f': '\f', 's': ' ',
134
+ '\\': '\\', '^': '^', ':': ':',
135
+ }
136
+
137
+ _TERMINFO_TO_TERMCAP_BOOL = {
138
+ "am": "am", "bce": "ut", "km": "km", "mc5i": "5i",
139
+ "mir": "mi", "msgr": "ms", "npc": "NP", "xenl": "xn",
140
+ "hs": "hs", "in": "in", "da": "da", "db": "db",
141
+ "eo": "eo", "eslok": "es", "gn": "gn", "hc": "hc",
142
+ "hz": "hz", "lpix": "YB", "ndscr": "ND", "nrrmc": "NR",
143
+ "os": "os", "sam": "SA", "ul": "ul", "xb": "xb",
144
+ "xn": "xn", "xt": "xt",
145
+ }
146
+
147
+ _TERMINFO_TO_TERMCAP_NUM = {
148
+ "colors": "Co", "cols": "co", "lines": "li", "it": "it",
149
+ "pairs": "pa", "btns": "BT", "bufsz": "Ya", "lm": "lm",
150
+ "lh": "lh", "lw": "lw", "ma": "ma", "mw": "mw",
151
+ "ncv": "NC", "nlab": "Nl", "pb": "pb", "sg": "sg",
152
+ "ug": "ug", "vt": "vt", "ws": "ws", "wnum": "dw",
153
+ "wsl": "ws",
154
+ }
155
+
156
+ _TERMINFO_TO_TERMCAP_STR = {
157
+ "acsc": "ac", "bel": "bl", "blink": "mb", "bold": "md",
158
+ "cbt": "bt", "civis": "vi", "clear": "cl", "cnorm": "ve",
159
+ "cr": "cr", "csr": "cs", "cub1": "le", "cud": "DO",
160
+ "cud1": "do", "cuf1": "nd", "cup": "cm", "cuu1": "up",
161
+ "cvvis": "vs", "dch1": "dC", "dim": "mh", "dl1": "dl",
162
+ "dsl": "ds", "ech": "ec", "ed": "cd", "el": "ce",
163
+ "el1": "cb", "flash": "vb", "fsl": "fs", "home": "ho",
164
+ "hpa": "ch", "hts": "st", "ht": "ta", "ich1": "ic",
165
+ "il1": "al", "ind": "sf", "invis": "mk", "iprog": "iP",
166
+ "is1": "i1", "is2": "is", "is3": "i3", "kb2": "K2",
167
+ "kbs": "kb", "kcbt": "kB", "kcub1": "kl", "kcud1": "kd",
168
+ "kcuf1": "kr", "kcuu1": "ku", "kdch1": "kD", "kend": "@7",
169
+ "kent": "@8", "kf1": "k1", "kf2": "k2", "kf3": "k3",
170
+ "kf4": "k4", "kf5": "k5", "kf6": "k6", "kf7": "k7",
171
+ "kf8": "k8", "kf9": "k9", "kf10": "k0", "kf11": "F1",
172
+ "kf12": "F2", "kfnd": "kF", "khlp": "%1", "khome": "kh",
173
+ "kich1": "kI", "kind": "kH", "knp": "kN", "kpp": "kP",
174
+ "kprv": "kR", "kspd": "@9", "ktbc": "ka", "mc0": "ps",
175
+ "mc4": "pf", "mc5": "po", "mc5p": "pO", "nel": "nw",
176
+ "op": "op", "pad": "pc", "pln": "pn", "prot": "mp",
177
+ "rep": "rp", "rev": "mr", "ri": "sr", "rmacs": "ae",
178
+ "rmcup": "te", "rmdc": "ed", "rmir": "ei", "rmkx": "ke",
179
+ "rmso": "se", "rmul": "ue", "rs1": "r1", "rs2": "r2",
180
+ "rs3": "r3", "sgr": "sa", "sgr0": "me", "sitm": "ZH",
181
+ "ritm": "ZR", "smacs": "as", "smcup": "ti", "smdc": "dm",
182
+ "smir": "im", "smkx": "ks", "smso": "so", "smul": "us",
183
+ "tbc": "ct", "tsl": "ts", "vpa": "cv", "wind": "wi",
184
+ "wingo": "WS",
185
+ }
186
+
187
+
188
+ def verbose(msg, enabled):
189
+ if enabled:
190
+ print(f"ttyscan: {msg}", file=sys.stderr)
191
+
192
+
193
+ def hex_encode(s):
194
+ return s.encode('ascii').hex()
195
+
196
+
197
+ def hex_decode(h):
198
+ try:
199
+ return bytes.fromhex(h).decode('ascii', errors='strict')
200
+ except ValueError:
201
+ return ''
202
+
203
+
204
+ def unescape_terminfo(value):
205
+ result = []
206
+ idx = 0
207
+ while idx < len(value):
208
+ cur = value[idx]
209
+ if cur == '\\' and idx + 1 < len(value):
210
+ nxt = value[idx + 1]
211
+ esc = _TERMINFO_ESCAPE.get(nxt)
212
+ if esc is not None:
213
+ result.append(esc)
214
+ idx += 2
215
+ continue
216
+ if nxt in '01234567':
217
+ end = idx + 1
218
+ while end < len(value) and value[end] in '01234567':
219
+ end += 1
220
+ result.append(chr(int(value[idx + 1:end], 8)))
221
+ idx = end
222
+ continue
223
+ elif cur == '^' and idx + 1 < len(value):
224
+ nxt = value[idx + 1]
225
+ if 'A' <= nxt <= '_':
226
+ result.append(chr(ord(nxt) - ord('A') + 1))
227
+ idx += 2
228
+ continue
229
+ if nxt == '?':
230
+ result.append('\x7f')
231
+ idx += 2
232
+ continue
233
+ result.append(cur)
234
+ idx += 1
235
+ return ''.join(result)
236
+
237
+
238
+ def open_tty():
239
+ try:
240
+ r_fd = os.open('/dev/tty', os.O_RDONLY | os.O_NOCTTY)
241
+ w_fd = os.open('/dev/tty', os.O_WRONLY | os.O_NOCTTY)
242
+ return r_fd, w_fd
243
+ except OSError as exc:
244
+ warn(f"cannot open /dev/tty: {exc}")
245
+ return None, None
246
+
247
+
248
+ def get_winsize(fd):
249
+ try:
250
+ buf = array.array('H', [0, 0, 0, 0])
251
+ fcntl.ioctl(fd, termios.TIOCGWINSZ, buf)
252
+ return buf[1] or 80, buf[0] or 24
253
+ except OSError:
254
+ return 80, 24
255
+
256
+
257
+ def set_cbreak(fd):
258
+ if fd is None:
259
+ return None
260
+ try:
261
+ saved = termios.tcgetattr(fd)
262
+ tty.setcbreak(fd)
263
+ return saved
264
+ except termios.error as exc:
265
+ warn(f"cannot set cbreak mode: {exc}")
266
+ return None
267
+
268
+
269
+ def restore_termios(fd, saved):
270
+ if fd is None or saved is None:
271
+ return
272
+ try:
273
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
274
+ except termios.error as exc:
275
+ warn(f"cannot restore terminal mode: {exc}")
276
+
277
+
278
+ def write_all(fd, data):
279
+ if fd is None:
280
+ return
281
+ if isinstance(data, str):
282
+ data = data.encode('ascii', errors='replace')
283
+ remaining = data
284
+ while remaining:
285
+ try:
286
+ n = os.write(fd, remaining)
287
+ remaining = remaining[n:]
288
+ except OSError as exc:
289
+ warn(f"write error: {exc}")
290
+ break
291
+
292
+
293
+ def read_available(r_fd, timeout):
294
+ buf = bytearray()
295
+ stime = time.monotonic()
296
+ max_size = 131072
297
+ while True:
298
+ timeleft = 0.0 if buf else timeout - (time.monotonic() - stime)
299
+ if not buf and timeleft <= 0:
300
+ break
301
+ try:
302
+ ready, _, _ = select.select([r_fd], [], [], timeleft)
303
+ except OSError as exc:
304
+ warn(f"select error: {exc}")
305
+ break
306
+ if not ready:
307
+ break
308
+ try:
309
+ chunk = os.read(r_fd, 4096)
310
+ except OSError as exc:
311
+ warn(f"read error: {exc}")
312
+ break
313
+ if not chunk:
314
+ break
315
+ buf.extend(chunk)
316
+ if len(buf) > max_size:
317
+ break
318
+ return bytes(buf)
319
+
320
+
321
+ def read_until(r_fd, w_fd, queries, pattern, timeout):
322
+ for q in queries:
323
+ write_all(w_fd, q)
324
+ write_all(w_fd, b'\x1b[6n')
325
+ stime = time.monotonic()
326
+ buf = ''
327
+ max_size = 131072
328
+ while True:
329
+ timeleft = timeout - (time.monotonic() - stime)
330
+ if timeleft <= 0:
331
+ break
332
+ raw = read_available(r_fd, timeleft)
333
+ if not raw:
334
+ break
335
+ buf += raw.decode('latin-1', errors='replace')
336
+ if (match := re.search(pattern, buf)) is not None:
337
+ return match, buf
338
+ if len(buf) > max_size:
339
+ break
340
+ return None, buf
341
+
342
+
343
+ def xtgettcap_query(r_fd, w_fd, caps, timeout):
344
+ if not caps:
345
+ return {}
346
+ queries = [f'\x1bP+q{hex_encode(c)}\x1b\\' for c in caps]
347
+ match, data = read_until(r_fd, w_fd, queries, _RE_CPR_BOUNDARY.pattern, timeout)
348
+ if match is None:
349
+ return {}
350
+ data = data[:match.start()] + data[match.end():]
351
+ capabilities = {}
352
+ for m in _RE_XTGETTCAP_RESPONSE.finditer(data):
353
+ if m.group(1) == '1':
354
+ name = hex_decode(m.group(2))
355
+ val_hex = m.group(3)
356
+ if val_hex is not None:
357
+ value = unescape_terminfo(hex_decode(val_hex))
358
+ else:
359
+ value = ''
360
+ capabilities[name] = value
361
+ return capabilities
362
+
363
+
364
+ def decrqm_query(r_fd, w_fd, mode, timeout):
365
+ result = read_until(r_fd, w_fd,
366
+ [f'\x1b[?{mode}$p'],
367
+ _RE_CPR_BOUNDARY.pattern, timeout)
368
+ if (match := result[0]) is None:
369
+ return None
370
+ data = result[1][:match.start()] + result[1][match.end():]
371
+ for m in _RE_DECRPM.finditer(data):
372
+ if int(m.group(1)) == mode:
373
+ return int(m.group(2))
374
+ return None
375
+
376
+
377
+ def detect_size(r_fd, w_fd, verbose_enabled):
378
+ has_inband = (decrpm := decrqm_query(r_fd, w_fd, 2048, 0.25)) is not None and decrpm == 2
379
+
380
+ if has_inband:
381
+ write_all(w_fd, '\x1b[?2048h')
382
+ raw = read_available(r_fd, 0.25).decode('latin-1', errors='replace')
383
+ if (resize_match := _RE_RESIZE.search(raw)):
384
+ rows = int(resize_match.group(1))
385
+ cols = int(resize_match.group(2))
386
+ verbose(f"size via Mode 2048 in-band: {rows}x{cols}", verbose_enabled)
387
+ write_all(w_fd, '\x1b[?2048l')
388
+ return rows, cols, 'inband'
389
+
390
+ cpr1_match, data = read_until(r_fd, w_fd, [], _RE_CPR.pattern, 0.25)
391
+ orig_y = orig_x = None
392
+ if cpr1_match:
393
+ orig_y = int(cpr1_match.group(1))
394
+ orig_x = int(cpr1_match.group(2))
395
+
396
+ write_all(w_fd, '\x1b[1000;1000H')
397
+ cpr2_match, _ = read_until(r_fd, w_fd, [], _RE_CPR.pattern, 0.25)
398
+
399
+ if has_inband:
400
+ write_all(w_fd, '\x1b[?2048l')
401
+
402
+ if orig_y is not None:
403
+ write_all(w_fd, f'\x1b[{orig_y};{orig_x}H')
404
+
405
+ if cpr2_match:
406
+ rows = int(cpr2_match.group(1))
407
+ cols = int(cpr2_match.group(2))
408
+ if rows == 999 or cols == 999:
409
+ verbose(f"rejecting bogus CPR size {rows}x{cols}", verbose_enabled)
410
+ return None
411
+ verbose(f"size via dual CPR: {rows}x{cols}", verbose_enabled)
412
+ return rows, cols, 'cpr'
413
+
414
+ verbose("CPR size detection", verbose_enabled)
415
+ write_all(w_fd, '\x1b[1000;1000H')
416
+ fb_match, _ = read_until(r_fd, w_fd, [], _RE_CPR.pattern, 0.25)
417
+ if fb_match:
418
+ rows = int(fb_match.group(1))
419
+ cols = int(fb_match.group(2))
420
+ if rows != 999 and cols != 999:
421
+ verbose(f"size via CPR: {rows}x{cols}", verbose_enabled)
422
+ return rows, cols, 'fallback_cpr'
423
+
424
+ verbose("size detection failed", verbose_enabled)
425
+ return None
426
+
427
+
428
+ def classify_caps(capabilities):
429
+ bool_caps = set()
430
+ num_caps = {}
431
+ str_caps = {}
432
+ for name, value in capabilities.items():
433
+ if name == 'RGB':
434
+ continue
435
+ if not value:
436
+ if name in _BOOL_SET:
437
+ bool_caps.add(name)
438
+ elif name in _NUM_SET:
439
+ if value.isdigit():
440
+ num_caps[name] = int(value)
441
+ elif name in _STR_SET:
442
+ str_caps[name] = value
443
+ return {'bool_caps': bool_caps, 'num_caps': num_caps, 'str_caps': str_caps}
444
+
445
+
446
+ def pack_short_le(buf, offset, value):
447
+ packed = struct.pack("<h", value)
448
+ buf[offset:offset + 2] = packed
449
+
450
+
451
+ def build_terminfo_binary(term, str_caps, num_caps, bool_caps):
452
+ names = f"{term}|XTGETTCAP-discovered terminal"
453
+ names_bytes = names.encode("ascii", errors="replace") + b"\x00"
454
+ name_size = len(names_bytes)
455
+ bool_indices = [_BOOL_INDEX[cap] for cap in bool_caps if cap in _BOOL_INDEX]
456
+ bool_max = max(bool_indices) + 1 if bool_indices else 0
457
+ bool_data = bytearray(bool_max)
458
+ for idx in bool_indices:
459
+ bool_data[idx] = 1
460
+ num_entries = [(_NUM_INDEX[cap], val) for cap, val in num_caps.items()
461
+ if cap in _NUM_INDEX]
462
+ num_max = max(idx for idx, _ in num_entries) + 1 if num_entries else 0
463
+ use_32bit = any(val > 32767 or val < -32768 for _, val in num_entries)
464
+ num_entry_size = 4 if use_32bit else 2
465
+ magic = _TERMINFO_MAGIC2 if use_32bit else _TERMINFO_MAGIC
466
+ num_data = bytearray(num_max * num_entry_size)
467
+ for idx, val in num_entries:
468
+ if use_32bit:
469
+ struct.pack_into("<i", num_data, idx * 4, val)
470
+ else:
471
+ pack_short_le(num_data, idx * 2, val)
472
+ num_used = set(idx for idx, _ in num_entries)
473
+ for i in range(num_max):
474
+ if i not in num_used:
475
+ if use_32bit:
476
+ struct.pack_into("<i", num_data, i * 4, _SENTINEL_ABSENT)
477
+ else:
478
+ pack_short_le(num_data, i * 2, _SENTINEL_ABSENT)
479
+ str_entries = [(_STR_INDEX[cap], val.encode("utf-8", errors="replace"))
480
+ for cap, val in str_caps.items() if cap in _STR_INDEX]
481
+ str_max = max(idx for idx, _ in str_entries) + 1 if str_entries else 0
482
+ str_table_parts = []
483
+ offsets = {}
484
+ for idx, raw in sorted(str_entries):
485
+ offsets[idx] = len(b"".join(str_table_parts))
486
+ str_table_parts.append(raw + b"\x00")
487
+ str_table = b"".join(str_table_parts)
488
+ offset_data = bytearray(str_max * 2)
489
+ for i in range(str_max):
490
+ if i in offsets:
491
+ pack_short_le(offset_data, i * 2, offsets[i])
492
+ else:
493
+ pack_short_le(offset_data, i * 2, _SENTINEL_ABSENT)
494
+ header = bytearray(12)
495
+ pack_short_le(header, 0, magic)
496
+ pack_short_le(header, 2, name_size)
497
+ pack_short_le(header, 4, bool_max)
498
+ pack_short_le(header, 6, num_max)
499
+ pack_short_le(header, 8, str_max)
500
+ pack_short_le(header, 10, len(str_table))
501
+ parts = [bytes(header), names_bytes, bytes(bool_data)]
502
+ if (name_size + bool_max) % 2 != 0:
503
+ parts.append(b"\x00")
504
+ parts.append(bytes(num_data))
505
+ parts.append(bytes(offset_data))
506
+ parts.append(str_table)
507
+ return b"".join(parts)
508
+
509
+
510
+ def sanitize_term_name(name):
511
+ cleaned = re.sub(r'[^a-zA-Z0-9._-]', '', name)
512
+ cleaned = cleaned.lstrip('.')
513
+ return cleaned if cleaned else 'unknown'
514
+
515
+
516
+ def terminfo_file_path(term, base_dir):
517
+ return base_dir / term[0] / term
518
+
519
+
520
+ def terminfo_installed_at(term, base_dir):
521
+ return terminfo_file_path(term, base_dir).exists()
522
+
523
+
524
+ def ttyscan_terminfo_dir():
525
+ xdg = os.environ.get(
526
+ "XDG_CONFIG_HOME",
527
+ os.path.join(os.path.expanduser("~"), ".config"),
528
+ )
529
+ return Path(xdg) / "ttyscan" / "terminfo"
530
+
531
+
532
+ def has_terminfo(term):
533
+ try:
534
+ import curses
535
+ curses.setupterm(term)
536
+ return bool(curses.tigetstr("clear"))
537
+ except Exception:
538
+ return False
539
+
540
+
541
+ def write_terminfo(term, str_caps, num_caps, bool_caps, dest_dir):
542
+ safe_term = sanitize_term_name(term)
543
+ data = build_terminfo_binary(safe_term, str_caps, num_caps, bool_caps)
544
+ file_path = terminfo_file_path(safe_term, dest_dir)
545
+ file_path.parent.mkdir(parents=True, exist_ok=True)
546
+ file_path.write_bytes(data)
547
+ return True
548
+
549
+
550
+ def escape_value(value, terminfo=False):
551
+ simple = {
552
+ "\x1b": "\\E", "\n": "\\n", "\t": "\\t", "\r": "\\r",
553
+ "\b": "\\b", "\f": "\\f", "\\": "\\\\", "^": "\\^",
554
+ }
555
+ if terminfo:
556
+ simple[" "] = "\\s"
557
+ simple[":"] = "\\:"
558
+ result = []
559
+ for ch in value:
560
+ if ch in simple:
561
+ result.append(simple[ch])
562
+ continue
563
+ code = ord(ch)
564
+ if code < 32:
565
+ result.append(f"^{chr(code + 64)}")
566
+ elif code == 127:
567
+ result.append("^?")
568
+ else:
569
+ result.append(ch)
570
+ return "".join(result)
571
+
572
+
573
+ def shell_escape(value):
574
+ if not value:
575
+ return "''"
576
+ escaped = value.replace("'", "'\\''")
577
+ return f"'{escaped}'"
578
+
579
+
580
+ def build_termcap_entry(term, str_caps, num_caps, bool_caps):
581
+ parts = [f"{term}|XTGETTCAP-discovered terminal:"]
582
+ for cap in sorted(bool_caps):
583
+ tc_name = _TERMINFO_TO_TERMCAP_BOOL.get(cap)
584
+ if tc_name:
585
+ parts.append(f":{tc_name}:")
586
+ for cap in sorted(num_caps):
587
+ tc_name = _TERMINFO_TO_TERMCAP_NUM.get(cap)
588
+ if tc_name:
589
+ parts.append(f":{tc_name}#{num_caps[cap]}:")
590
+ for cap in sorted(str_caps):
591
+ tc_name = _TERMINFO_TO_TERMCAP_STR.get(cap)
592
+ if tc_name:
593
+ value = escape_value(str_caps[cap])
594
+ parts.append(f":{tc_name}={value}:")
595
+ return "".join(parts)
596
+
597
+
598
+ def normalize_terminal_name(raw):
599
+ if raw == "WezTerm":
600
+ return "wezterm"
601
+ return raw
602
+
603
+
604
+ def has_meaningful_caps(str_caps, num_caps, bool_caps):
605
+ screen_str_caps = {k for k in str_caps if not k.startswith('k')}
606
+ if screen_str_caps:
607
+ return True
608
+ if bool_caps:
609
+ return True
610
+ meaningful_nums = {k for k in num_caps if k not in ('colors', 'Co')}
611
+ if meaningful_nums:
612
+ return True
613
+ return False
614
+
615
+
616
+ def check_colorterm(caps, force):
617
+ rgb = caps.get("RGB", "")
618
+ try:
619
+ if rgb and int(rgb.split("/", 1)[0]) == 8:
620
+ if force or os.environ.get("COLORTERM") != "truecolor":
621
+ return "export COLORTERM=truecolor"
622
+ except ValueError:
623
+ pass
624
+ return None
625
+
626
+
627
+ def check_term(caps, force):
628
+ tn = caps.get("TN")
629
+ if tn:
630
+ tn = normalize_terminal_name(tn)
631
+ if force or tn != os.environ.get("TERM"):
632
+ return f"export TERM={tn}"
633
+ return None
634
+
635
+
636
+ def check_lines_columns(rows, cols, winsize, force):
637
+ term_cols, term_rows = winsize
638
+ if not force and (rows, cols) == (term_rows, term_cols):
639
+ if ((env_lines := os.environ.get("LINES")) is None or env_lines == str(rows)) and \
640
+ ((env_cols := os.environ.get("COLUMNS")) is None or env_cols == str(cols)):
641
+ return None
642
+ exports = []
643
+ env_lines = os.environ.get("LINES")
644
+ env_cols = os.environ.get("COLUMNS")
645
+ if force or env_lines != str(rows):
646
+ exports.append(f"export LINES={rows}")
647
+ if force or env_cols != str(cols):
648
+ exports.append(f"export COLUMNS={cols}")
649
+ return exports or None
650
+
651
+
652
+ def generate_exports(verbose_enabled=False, force=False, termcap=False):
653
+ verbose("probing terminal via XTGETTCAP ...", verbose_enabled)
654
+
655
+ r_fd, w_fd = open_tty()
656
+ if r_fd is None or w_fd is None:
657
+ verbose("no terminal available", verbose_enabled)
658
+ return []
659
+
660
+ saved = set_cbreak(r_fd)
661
+ try:
662
+ winsize = get_winsize(r_fd)
663
+
664
+ init_caps = xtgettcap_query(r_fd, w_fd, _INIT_XTGETTCAP_CAPS,
665
+ timeout=_TTYSCAN_QUERY_TIMEOUT)
666
+ if not init_caps:
667
+ verbose("XTGETTCAP not supported by this terminal", verbose_enabled)
668
+ return []
669
+
670
+ tn_raw = init_caps.get("TN")
671
+ if not tn_raw:
672
+ verbose("no TN (terminal name) capability, cannot export further",
673
+ verbose_enabled)
674
+ return []
675
+
676
+ tn = normalize_terminal_name(tn_raw)
677
+
678
+ exports = []
679
+
680
+ ct_export = check_colorterm(init_caps, force)
681
+ if ct_export:
682
+ exports.append(ct_export)
683
+
684
+ term_export = check_term(init_caps, force)
685
+ if term_export:
686
+ exports.append(term_export)
687
+
688
+ size = detect_size(r_fd, w_fd, verbose_enabled)
689
+ if size:
690
+ rows, cols, source = size
691
+ term_cols, term_rows = winsize
692
+ if source != 'inband' and (
693
+ rows == 999 or cols == 999
694
+ or rows < term_rows or cols < term_cols
695
+ ):
696
+ verbose(
697
+ f"rejecting CPR size {rows}x{cols} "
698
+ f"(term reports {term_rows}x{term_cols})",
699
+ verbose_enabled,
700
+ )
701
+ else:
702
+ lc = check_lines_columns(rows, cols, winsize, force)
703
+ if lc:
704
+ exports.extend(lc)
705
+
706
+ if not force and not termcap and has_terminfo(tn):
707
+ verbose(f"XTGETTCAP supported, terminal: {tn}", verbose_enabled)
708
+ verbose("terminfo already available in system database", verbose_enabled)
709
+ return exports
710
+
711
+ remaining_caps = [c for c in _FULL_XTGETTCAP_CAPS
712
+ if c not in _INIT_XTGETTCAP_CAPS]
713
+ t0 = time.monotonic()
714
+ full_caps = xtgettcap_query(r_fd, w_fd, remaining_caps,
715
+ timeout=_TTYSCAN_QUERY_TIMEOUT)
716
+ elapsed_ms = int((time.monotonic() - t0) * 1000)
717
+
718
+ full_caps.update(init_caps)
719
+ classified = classify_caps(full_caps)
720
+ str_caps = classified['str_caps']
721
+ num_caps = classified['num_caps']
722
+ bool_caps = classified['bool_caps']
723
+
724
+ n_caps = len(full_caps)
725
+ verbose(
726
+ f"XTGETTCAP supported, terminal: {tn}; "
727
+ f"received {n_caps} caps "
728
+ f"({len(bool_caps)} bool, {len(num_caps)} num, {len(str_caps)} str) "
729
+ f"in {elapsed_ms}ms",
730
+ verbose_enabled,
731
+ )
732
+
733
+ if not has_meaningful_caps(str_caps, num_caps, bool_caps):
734
+ verbose(
735
+ "only bare-minimum capabilities (TN/colors/RGB), "
736
+ "skipping TERMINFO/TERMCAP export",
737
+ verbose_enabled,
738
+ )
739
+ return exports
740
+
741
+ terminfo_dir = ttyscan_terminfo_dir()
742
+ safe_term = sanitize_term_name(tn)
743
+
744
+ if not has_terminfo(tn) or force:
745
+ write_terminfo(tn, str_caps, num_caps, bool_caps, terminfo_dir)
746
+ verbose(f"writing {terminfo_file_path(safe_term, terminfo_dir)}",
747
+ verbose_enabled)
748
+ terminfo_value = str(terminfo_dir)
749
+ current_terminfo = os.environ.get("TERMINFO")
750
+ if force or current_terminfo != terminfo_value:
751
+ exports.append(f"export TERMINFO={terminfo_value}")
752
+ else:
753
+ verbose("terminfo already available in system database", verbose_enabled)
754
+
755
+ if termcap:
756
+ entry = build_termcap_entry(tn, str_caps, num_caps, bool_caps)
757
+ if entry and (force or os.environ.get("TERMCAP") != entry):
758
+ exports.append(f"export TERMCAP={shell_escape(entry)}")
759
+
760
+ return exports
761
+
762
+ finally:
763
+ restore_termios(r_fd, saved)
764
+ if r_fd is not None:
765
+ try:
766
+ os.close(r_fd)
767
+ except OSError:
768
+ pass
769
+ if w_fd is not None:
770
+ try:
771
+ os.close(w_fd)
772
+ except OSError:
773
+ pass
774
+
775
+
776
+ def main(argv=None):
777
+ parser = argparse.ArgumentParser(
778
+ prog="ttyscan",
779
+ description="Export terminal capabilities discovered via XTGETTCAP",
780
+ )
781
+ parser.add_argument(
782
+ "-v", "--verbose", action="store_true",
783
+ help="Print diagnostic information to stderr",
784
+ )
785
+ parser.add_argument(
786
+ "-f", "--force", action="store_true",
787
+ help="Force export of all values even if unchanged",
788
+ )
789
+ parser.add_argument(
790
+ "-t", "--termcap", action="store_true",
791
+ help="Also export TERMCAP value",
792
+ )
793
+ args = parser.parse_args(argv)
794
+
795
+ exports = generate_exports(
796
+ verbose_enabled=args.verbose,
797
+ force=args.force,
798
+ termcap=args.termcap,
799
+ )
800
+ for line in exports:
801
+ print(line)
802
+
803
+
804
+ if __name__ == '__main__':
805
+ main()