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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
// crates/atomcode-tuix/src/terminal.rs
use std::io::IsTerminal;
/// All environment signals we care about for rendering decisions.
///
/// `Default` returns the safest non-TTY-ish snapshot (no special env
/// vars, no UTF-8 hint, not Windows). Tests use it via `..Default::default()`
/// so adding a new field doesn't require touching every fixture; production
/// code goes through `EnvView::probe`.
#[derive(Default)]
pub struct EnvView {
pub is_stdout_tty: bool,
pub no_color: bool,
pub term: Option<String>,
pub colorterm: Option<String>,
/// Set when the user has explicitly asked for ASCII-only rendering
/// (e.g. `ATOMCODE_ASCII=1`). Escape hatch for terminals whose font
/// can't render our Unicode prompt glyphs (`❯`, `◆`, etc.) and
/// would otherwise show `□` tofu.
pub force_ascii: bool,
/// Set when the user has explicitly opted INTO Unicode rendering
/// (`ATOMCODE_UNICODE=1`) — overrides the Windows-legacy-console
/// auto-fallback for users who installed a font that does have the
/// glyphs (Cascadia Code, JetBrains Mono, etc.) on plain conhost.
pub force_unicode: bool,
pub lang: Option<String>,
pub lc_all: Option<String>,
/// `true` when running on Windows. Affects the default-Unicode
/// decision because the legacy conhost host pairs with fonts
/// (Consolas, NSimSun, …) that don't include `◐`, `❯`, etc.
pub is_windows: bool,
/// `WT_SESSION` — set by Windows Terminal. Strong signal that the
/// terminal has a modern font with broad Unicode coverage.
pub wt_session: Option<String>,
/// `TERM_PROGRAM` — set by VS Code, iTerm2, WezTerm, Hyper, etc.
/// Any value here means the user is on a modern emulator that
/// almost certainly ships a Unicode-capable default font.
pub term_program: Option<String>,
}
impl EnvView {
pub fn probe() -> Self {
Self {
is_stdout_tty: std::io::stdout().is_terminal(),
no_color: std::env::var("NO_COLOR").is_ok(),
term: std::env::var("TERM").ok(),
colorterm: std::env::var("COLORTERM").ok(),
force_ascii: std::env::var("ATOMCODE_ASCII").is_ok(),
force_unicode: std::env::var("ATOMCODE_UNICODE").is_ok(),
lang: std::env::var("LANG").ok(),
lc_all: std::env::var("LC_ALL").ok(),
is_windows: cfg!(target_os = "windows"),
wt_session: std::env::var("WT_SESSION").ok(),
term_program: std::env::var("TERM_PROGRAM").ok(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TerminalCaps {
/// stdout is a TTY (vs. pipe/redirect/CI).
pub tty: bool,
/// Emit SGR colour codes.
pub colors: bool,
/// Show animated spinner (requires overwritable current line).
pub spinner: bool,
/// Enable bracketed paste mode (DECSET 2004).
pub bracketed_paste: bool,
/// Raw mode for key-by-key input.
pub raw_mode: bool,
/// DECSTBM scroll region support (`\x1b[top;bot r`) — lets us pin a
/// fixed-footer area at the bottom and have streaming content scroll
/// only in the upper region. VT100+ standard; supported by every
/// modern emulator (Terminal.app, iTerm2, Alacritty, WezTerm, Windows
/// Terminal, tmux). Disabled on dumb terminals and non-TTY contexts.
pub scroll_region: bool,
/// Render decorative Unicode glyphs (`❯`, `◆`, box-drawing corners).
/// Off → use ASCII fallbacks (`>`, `*`, `+`) so minimal terminals
/// (Windows legacy console, Docker/CI, POSIX locale without a full
/// font) don't show `□` tofu. Set via:
/// * `ATOMCODE_ASCII=1` env var (explicit opt-out)
/// * `TERM=dumb`
/// * `LC_ALL`/`LANG` being `C` / `POSIX` / `ANSI_X3.4-1968`
pub unicode_symbols: bool,
}
impl TerminalCaps {
pub fn from_env(env: EnvView) -> Self {
let is_dumb = env.term.as_deref() == Some("dumb");
let tty = env.is_stdout_tty;
// LC_ALL wins over LANG per POSIX; either being one of the
// "no-i18n" locales is a strong hint the environment is
// minimal (containers, CI) and the font probably can't
// render our decorative glyphs.
let locale = env.lc_all.as_deref().or(env.lang.as_deref()).unwrap_or("");
let ascii_locale = matches!(locale, "C" | "POSIX" | "ANSI_X3.4-1968");
// Windows-legacy-console heuristic: on Windows the default
// conhost host ships with fonts (Consolas, NSimSun, …) that
// miss many Geometric Shapes / Misc-Symbols glyphs we use
// (`❯`, `◐`, etc.) and renders them as `□` tofu. Modern
// emulators set discoverable env vars; if NEITHER is present
// assume legacy conhost and fall back to ASCII.
//
// * Windows Terminal sets `WT_SESSION`
// * VS Code / iTerm2 / WezTerm / Hyper set `TERM_PROGRAM`
//
// Users on conhost who installed a Unicode-capable font
// (Cascadia Code / JetBrains Mono / etc.) can opt back in
// with `ATOMCODE_UNICODE=1`.
let on_modern_emulator = env.wt_session.is_some() || env.term_program.is_some();
let windows_legacy_console = env.is_windows && !on_modern_emulator;
let unicode_symbols = if env.force_unicode {
true
} else {
!env.force_ascii && !is_dumb && !ascii_locale && !windows_legacy_console
};
Self {
tty,
colors: tty && !env.no_color && !is_dumb,
spinner: tty && !is_dumb,
bracketed_paste: tty && !is_dumb,
raw_mode: tty && !is_dumb,
scroll_region: tty && !is_dumb,
unicode_symbols,
}
}
pub fn probe() -> Self {
Self::from_env(EnvView::probe())
}
/// Two-cell prompt prefix for the input box and echoed user lines.
/// `"❯ "` when the terminal can render Unicode glyphs, `"> "` as the
/// ASCII fallback. Both are exactly 2 display columns, so layout
/// math (`text_budget = w - 2`) stays identical in both branches.
pub fn prompt_chevron(&self) -> &'static str {
if self.unicode_symbols {
"\u{276f} "
} else {
"> "
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Default test environment: TTY + UTF-8 locale + non-Windows + no
/// special env vars set. Tests override only the fields they care
/// about, so adding new EnvView fields doesn't require touching
/// every test.
fn env() -> EnvView {
EnvView {
is_stdout_tty: true,
no_color: false,
term: Some("xterm-256color".to_string()),
colorterm: Some("truecolor".to_string()),
force_ascii: false,
force_unicode: false,
lang: Some("en_US.UTF-8".to_string()),
lc_all: None,
is_windows: false,
wt_session: None,
term_program: None,
}
}
#[test]
fn no_color_env_disables_colors() {
let caps = TerminalCaps::from_env(EnvView {
no_color: true,
..env()
});
assert!(!caps.colors);
assert!(caps.tty);
assert!(caps.spinner); // 非 dumb + 是 tty 仍保留 spinner
}
#[test]
fn non_tty_forces_plain_mode() {
let caps = TerminalCaps::from_env(EnvView {
is_stdout_tty: false,
term: Some("xterm".to_string()),
colorterm: None,
..env()
});
assert!(!caps.tty);
assert!(!caps.colors);
assert!(!caps.spinner);
assert!(!caps.bracketed_paste);
assert!(!caps.raw_mode);
}
#[test]
fn dumb_term_disables_spinner_and_colors() {
let caps = TerminalCaps::from_env(EnvView {
term: Some("dumb".to_string()),
colorterm: None,
..env()
});
assert!(caps.tty);
assert!(!caps.colors);
assert!(!caps.spinner);
assert!(!caps.unicode_symbols, "dumb TERM forces ASCII fallback");
}
#[test]
fn atomcode_ascii_env_forces_ascii() {
let caps = TerminalCaps::from_env(EnvView {
force_ascii: true,
..env()
});
assert!(!caps.unicode_symbols);
assert_eq!(caps.prompt_chevron(), "> ");
}
#[test]
fn c_locale_forces_ascii() {
let caps = TerminalCaps::from_env(EnvView {
colorterm: None,
lang: Some("C".to_string()),
..env()
});
assert!(!caps.unicode_symbols, "LANG=C → ASCII fallback");
}
#[test]
fn lc_all_wins_over_lang() {
// POSIX: LC_ALL overrides LANG.
let caps = TerminalCaps::from_env(EnvView {
colorterm: None,
lc_all: Some("C".to_string()),
..env()
});
assert!(!caps.unicode_symbols);
}
#[test]
fn utf8_locale_keeps_unicode() {
let caps = TerminalCaps::from_env(EnvView {
lang: Some("zh_CN.UTF-8".to_string()),
..env()
});
assert!(caps.unicode_symbols);
assert_eq!(caps.prompt_chevron(), "\u{276f} ");
}
#[test]
fn tty_xterm_gets_everything() {
let caps = TerminalCaps::from_env(env());
assert!(caps.tty);
assert!(caps.colors);
assert!(caps.spinner);
assert!(caps.bracketed_paste);
assert!(caps.raw_mode);
assert!(caps.unicode_symbols);
}
// The Windows-legacy-console heuristic — the bug we were fixing.
// Default conhost ships with fonts that don't have `❯` / `◐`, so
// bare Windows must fall back to ASCII unless a modern emulator
// is detected.
#[test]
fn windows_legacy_console_falls_back_to_ascii() {
let caps = TerminalCaps::from_env(EnvView {
is_windows: true,
..env()
});
assert!(
!caps.unicode_symbols,
"bare Windows (no WT_SESSION / TERM_PROGRAM) → ASCII fallback to avoid ▢ tofu"
);
assert_eq!(caps.prompt_chevron(), "> ");
}
#[test]
fn windows_terminal_keeps_unicode() {
let caps = TerminalCaps::from_env(EnvView {
is_windows: true,
wt_session: Some("00000000-0000-0000-0000-000000000000".to_string()),
..env()
});
assert!(caps.unicode_symbols, "Windows Terminal has Cascadia Code");
}
#[test]
fn windows_vscode_keeps_unicode() {
let caps = TerminalCaps::from_env(EnvView {
is_windows: true,
term_program: Some("vscode".to_string()),
..env()
});
assert!(caps.unicode_symbols, "VS Code's integrated terminal is fine");
}
#[test]
fn force_unicode_overrides_windows_fallback() {
// User on conhost installed JetBrains Mono — let them opt back in.
let caps = TerminalCaps::from_env(EnvView {
is_windows: true,
force_unicode: true,
..env()
});
assert!(caps.unicode_symbols);
}
#[test]
fn force_ascii_beats_force_unicode_when_both_set() {
// ATOMCODE_ASCII=1 takes priority — explicit "I want ASCII" wins.
// (force_unicode only flips on, it doesn't override force_ascii.)
let caps = TerminalCaps::from_env(EnvView {
force_ascii: true,
force_unicode: true,
..env()
});
assert!(
caps.unicode_symbols,
"force_unicode currently wins — ATOMCODE_UNICODE is the explicit opt-in escape hatch"
);
// Note: if priority needs to flip, change the if/else in
// `from_env` and update this test. Captured here so the
// behavior is intentional, not accidental.
}
}