1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
//! Passive environment detection for [`Capabilities`]: the TERM /
//! TERM_PROGRAM / COLORTERM evidence pass. `#[path]` sibling of
//! caps.rs (file-size split) — `detect_env` stays a `Capabilities`
//! method; only the file moved.
//!
//! OWNER: PROBE.
use super::Capabilities;
impl Capabilities {
/// Passive detection from the process environment.
///
/// Free and instant — run it before first paint; the active probe
/// (`input::probe_active`) upgrades the result concurrently with the
/// first frame (design doc §2.3).
///
/// ```
/// use abstracttui::term::Capabilities;
///
/// let caps = Capabilities::detect_env();
/// // Feed the presenter and the graphics ladder from the same facts:
/// let present = caps.present_caps();
/// let gfx = caps.graphics();
/// println!("{}", caps.summary_line()); // e.g. "truecolor, kitty-kbd, …"
/// # let _ = (present, gfx);
/// ```
pub fn detect_env() -> Self {
Self::detect_env_with(&|k| std::env::var(k).ok())
}
/// Same, with an injectable lookup so tests never touch process env.
pub fn detect_env_with(lookup: &dyn Fn(&str) -> Option<String>) -> Self {
let get = |k: &str| lookup(k).unwrap_or_default();
let term = get("TERM").to_ascii_lowercase();
let term_program = get("TERM_PROGRAM");
let colorterm = get("COLORTERM").to_ascii_lowercase();
let mut c = Capabilities::default();
// A terminal that identifies as dumb (or nothing at all) gets
// nothing; emitting escapes at a dumb terminal is worse than plain
// text. The linux console parses CSI but has no mouse/paste/focus.
let dumb = term.is_empty() && term_program.is_empty() || term == "dumb";
let linux_console = term == "linux";
c.in_tmux =
!get("TMUX").is_empty() || term.starts_with("tmux") || term.starts_with("screen");
let kitty = !get("KITTY_WINDOW_ID").is_empty() || term == "xterm-kitty";
let wezterm = term_program == "WezTerm" || !get("WEZTERM_EXECUTABLE").is_empty();
let ghostty = term_program == "ghostty" || !get("GHOSTTY_RESOURCES_DIR").is_empty();
let iterm2 = term_program == "iTerm.app" || !get("ITERM_SESSION_ID").is_empty();
let windows_terminal = !get("WT_SESSION").is_empty();
let apple_terminal = term_program == "Apple_Terminal";
// TERM_PROGRAM=vscode: VS Code's integrated terminal and its
// forks (Cursor, VSCodium…) — all xterm.js. Env evidence covers
// truecolor, OSC 8 hyperlinks (xterm.js >= 4.3) and focus
// reporting (DEC 1004) through the `modern` set below. NOT
// claimed from env: OSC 52 (settings-gated permission prompt),
// kitty keyboard/graphics and sixel (absent or addon, off by
// default) — the active probe is the only door for those.
let vscode = term_program == "vscode";
let foot = term == "foot" || term.starts_with("foot-");
let vte_version: u32 = get("VTE_VERSION").parse().unwrap_or(0);
let modern = kitty || wezterm || ghostty || iterm2 || windows_terminal || foot || vscode;
c.truecolor =
colorterm == "truecolor" || colorterm == "24bit" || term.contains("direct") || modern;
c.colors_256 = c.truecolor || term.contains("256color") || apple_terminal;
// Kitty keyboard: claimed only for terminals that speak it OUT OF
// THE BOX (it is part of their identity). WezTerm supports the
// protocol but ships `enable_kitty_keyboard = false` by default —
// an env claim there over-promises exactly where the user did not
// configure it (backlog 0293), so WezTerm's claim is evidence-
// gated: the active probe's `CSI ? u` raises it when the user
// enabled the protocol, and the driver pushes the enter flags at
// that moment (`Terminal::set_kitty_keyboard`).
c.kitty_keyboard = kitty || ghostty || foot;
// WezTerm's kitty-graphics support is partial; its iTerm2 path is
// complete, so we prefer that there and let the active probe raise
// kitty_graphics only when the terminal proves it.
c.kitty_graphics = kitty || ghostty;
c.iterm2_images = iterm2 || wezterm;
c.sixel = foot || term.contains("sixel");
c.sync_output_2026 = modern;
c.hyperlinks = modern || vte_version >= 5000;
// SGR 4:3 undercurl: kitty lineage + VTE 0.52+ (52xx) + iTerm2 +
// Windows Terminal (1.18+; env cannot see versions, accept).
c.undercurl = kitty
|| wezterm
|| ghostty
|| foot
|| iterm2
|| windows_terminal
|| vte_version >= 5200;
c.underline_color = c.undercurl; // same lineage, same evidence today
// OSC 52 write: default-on in the kitty/wezterm/ghostty/foot/iterm2
// lineage and Windows Terminal; xterm gates it behind allowWindowOps
// (off by default) and VTE only grew it recently — both stay false
// without evidence. tmux translates OSC 52 itself (set-clipboard
// defaults to "external"), so in_tmux does not clear it.
c.osc52_copy = kitty || wezterm || ghostty || foot || iterm2 || windows_terminal;
// Desktop notifications, two dialects: OSC 9 (iTerm2 convention:
// iTerm2/WezTerm/ghostty) and OSC 99 (kitty's protocol — kitty
// never adopted OSC 9). ghostty speaks both; it stays on OSC 9 so
// it can never double-notify. foot's OSC 777 remains deferred
// until a consumer asks.
c.osc9_notify = iterm2 || wezterm || ghostty;
c.osc99_notify = kitty;
// Near-universal in the modern era; the exceptions are terminals
// that predate the modern era entirely.
let interactive = !dumb && !linux_console;
c.sgr_mouse = interactive;
c.bracketed_paste = interactive;
c.focus_events = interactive && !apple_terminal;
// The user's explicit no-color request outranks terminal ability
// (informal NO_COLOR spec: any non-empty value counts).
c.no_color = !get("NO_COLOR").is_empty();
if c.no_color {
c.truecolor = false;
c.colors_256 = false;
}
if c.in_tmux {
// Graphics escapes reach the outer terminal only through
// passthrough wrapping (`term::tmux_wrap`) AND the user's
// allow-passthrough setting, which is off by default since
// tmux 3.3a and undetectable from env. Claiming support would
// draw garbage on default configs: disabled, labeled, and the
// passthrough need is recorded for a verified path later.
c.kitty_graphics = false;
c.iterm2_images = false;
c.sixel = false;
c.needs_tmux_passthrough = true;
if term_program == "tmux" {
let v = get("TERM_PROGRAM_VERSION");
if !v.is_empty() {
c.tmux_version = Some(v);
}
}
}
#[cfg(windows)]
{
c.unicode_ok = true; // enter() sets the UTF-8 codepage.
// RT1-12b: classic conhost does not translate mouse into VT
// sequences under ENABLE_VIRTUAL_TERMINAL_INPUT — mouse would
// be silently dead. Claim SGR mouse only inside a terminal
// that identified itself as a modern emulator (Windows
// Terminal, kitty, WezTerm, ghostty, iTerm2, foot, VS Code,
// or anything setting TERM_PROGRAM); bare conhost degrades
// to keyboard-only, honestly.
c.sgr_mouse = c.sgr_mouse && (modern || !term_program.is_empty());
}
#[cfg(not(windows))]
{
let locale = [get("LC_ALL"), get("LC_CTYPE"), get("LANG")]
.into_iter()
.find(|v| !v.is_empty())
.unwrap_or_default()
.to_ascii_lowercase();
c.unicode_ok = locale.contains("utf-8") || locale.contains("utf8");
}
if dumb {
let unicode_ok = c.unicode_ok;
let in_tmux = c.in_tmux;
let no_color = c.no_color;
let needs_tmux_passthrough = c.needs_tmux_passthrough;
let tmux_version = c.tmux_version.take();
c = Capabilities::default();
c.unicode_ok = unicode_ok;
c.in_tmux = in_tmux;
c.no_color = no_color;
c.needs_tmux_passthrough = needs_tmux_passthrough;
c.tmux_version = tmux_version;
c.dumb = true;
}
c
}
}