fix(tui): calm legacy Windows console rendering (#1655)

This commit is contained in:
Hunter Bown
2026-05-14 19:00:52 -05:00
committed by GitHub
parent 7c8c71eb03
commit b834548897
5 changed files with 166 additions and 11 deletions
+7 -1
View File
@@ -43,6 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`composer_arrows_scroll` now defaults on for Windows terminals even when
mouse capture is enabled, so wheel events that arrive as arrow keys scroll the
transcript instead of cycling composer history (#1578).
- **Plain Windows PowerShell / ConHost uses calmer rendering.** Unmarked
legacy Windows console hosts now automatically enable low-motion rendering,
disable fancy animations, and resolve `synchronized_output = "auto"` to off
so streaming redraws do not overlap or visibly flicker (#1590).
### Thanks
@@ -53,7 +57,9 @@ switching fixes harvested from #1642. Thanks to **Photo
([@eng2007](https://github.com/eng2007))** for the provider-aware `/model`
picker catalog work harvested from #1201. Thanks to
**[@kunpeng-ai-lab](https://github.com/kunpeng-ai-lab)** for the Windows
composer scroll fix harvested from #1578.
composer scroll fix harvested from #1578, and **WuMing
([@asdfg314284230](https://github.com/asdfg314284230))** for the Windows
PowerShell flicker fix harvested from #1591.
## [0.8.37] - 2026-05-14
+7 -1
View File
@@ -43,6 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`composer_arrows_scroll` now defaults on for Windows terminals even when
mouse capture is enabled, so wheel events that arrive as arrow keys scroll the
transcript instead of cycling composer history (#1578).
- **Plain Windows PowerShell / ConHost uses calmer rendering.** Unmarked
legacy Windows console hosts now automatically enable low-motion rendering,
disable fancy animations, and resolve `synchronized_output = "auto"` to off
so streaming redraws do not overlap or visibly flicker (#1590).
### Thanks
@@ -53,7 +57,9 @@ switching fixes harvested from #1642. Thanks to **Photo
([@eng2007](https://github.com/eng2007))** for the provider-aware `/model`
picker catalog work harvested from #1201. Thanks to
**[@kunpeng-ai-lab](https://github.com/kunpeng-ai-lab)** for the Windows
composer scroll fix harvested from #1578.
composer scroll fix harvested from #1578, and **WuMing
([@asdfg314284230](https://github.com/asdfg314284230))** for the Windows
PowerShell flicker fix harvested from #1591.
## [0.8.37] - 2026-05-14
+7
View File
@@ -2398,6 +2398,13 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
);
any_quirk = true;
}
if crate::settings::detected_legacy_windows_console_host() {
println!(
" {} legacy Windows console host → low_motion + fancy_animations=false + synchronized_output=off (auto)",
"".truecolor(sky_r, sky_g, sky_b)
);
any_quirk = true;
}
if !any_quirk {
println!(
" {} no env-driven terminal-quirk overrides active",
+136
View File
@@ -411,6 +411,18 @@ impl Settings {
self.fancy_animations = false;
}
// Plain Windows PowerShell / cmd.exe under legacy ConHost exposes none
// of the modern terminal markers below. Keep rendering calmer there:
// lower the motion rate, disable animated chrome, and avoid DEC 2026
// synchronized-output wrapping unless the user explicitly forced it on.
if detected_legacy_windows_console_host() {
self.low_motion = true;
self.fancy_animations = false;
if self.synchronized_output.eq_ignore_ascii_case("auto") {
self.synchronized_output = "off".to_string();
}
}
// Ptyxis 50.x (the new default terminal on Ubuntu 26.04) ships with
// VTE 0.84.x which mishandles DEC mode 2026 synchronized output: the
// begin/end pair is parsed but each wrapped frame still triggers a
@@ -907,6 +919,31 @@ pub fn detected_ptyxis_terminal() -> bool {
matches!(std::env::var("PTYXIS_VERSION"), Ok(v) if !v.trim().is_empty())
}
/// Returns `true` for the unmarked Windows console-host path used by plain
/// PowerShell / cmd.exe. Modern Windows terminals set at least one marker that
/// lets us keep the richer rendering path.
pub fn detected_legacy_windows_console_host() -> bool {
cfg!(windows)
&& legacy_windows_console_host_env([
std::env::var_os("WT_SESSION").as_deref(),
std::env::var_os("ConEmuPID").as_deref(),
std::env::var_os("TERM_PROGRAM").as_deref(),
std::env::var_os("WEZTERM_EXECUTABLE").as_deref(),
std::env::var_os("WEZTERM_PANE").as_deref(),
std::env::var_os("ALACRITTY_WINDOW_ID").as_deref(),
std::env::var_os("ANSICON").as_deref(),
std::env::var_os("TERM").as_deref(),
])
}
fn legacy_windows_console_host_env(markers: [Option<&std::ffi::OsStr>; 8]) -> bool {
fn has_value(value: Option<&std::ffi::OsStr>) -> bool {
value.is_some_and(|v| !v.is_empty())
}
markers.into_iter().all(|value| !has_value(value))
}
fn normalize_optional_background_color(value: Option<&str>) -> Option<String> {
value.and_then(|raw| normalize_background_color_setting(raw).ok().flatten())
}
@@ -1200,6 +1237,14 @@ mod tests {
#[test]
fn no_animations_env_recognises_truthy_spellings_only() {
let _g = no_animations_test_guard();
let prev_wt_session = std::env::var_os("WT_SESSION");
// The test is about NO_ANIMATIONS only. On Windows CI, an unmarked
// console host now independently enables low_motion, so mark the host
// as non-legacy while checking falsy spellings.
#[cfg(windows)]
unsafe {
std::env::set_var("WT_SESSION", "test");
}
for truthy in ["1", "true", "True", "YES", "on"] {
// SAFETY: serialised by the guard.
unsafe {
@@ -1221,6 +1266,10 @@ mod tests {
// SAFETY: cleanup under the guard.
unsafe {
std::env::remove_var("NO_ANIMATIONS");
match prev_wt_session {
Some(v) => std::env::set_var("WT_SESSION", v),
None => std::env::remove_var("WT_SESSION"),
}
}
}
@@ -1417,6 +1466,93 @@ mod tests {
}
}
#[test]
fn legacy_windows_console_host_detects_unmarked_shell() {
assert!(legacy_windows_console_host_env([
None, None, None, None, None, None, None, None
]));
}
#[test]
fn legacy_windows_console_host_excludes_modern_terminal_markers() {
use std::ffi::OsStr;
let marker = Some(OsStr::new("1"));
assert!(!legacy_windows_console_host_env([
marker, None, None, None, None, None, None, None
]));
assert!(!legacy_windows_console_host_env([
None, marker, None, None, None, None, None, None
]));
assert!(!legacy_windows_console_host_env([
None, None, marker, None, None, None, None, None
]));
assert!(!legacy_windows_console_host_env([
None, None, None, marker, None, None, None, None
]));
assert!(!legacy_windows_console_host_env([
None, None, None, None, marker, None, None, None
]));
assert!(!legacy_windows_console_host_env([
None, None, None, None, None, marker, None, None
]));
assert!(!legacy_windows_console_host_env([
None, None, None, None, None, None, marker, None
]));
assert!(!legacy_windows_console_host_env([
None, None, None, None, None, None, None, marker
]));
}
#[cfg(windows)]
#[test]
fn unmarked_windows_console_forces_calm_rendering() {
let _g = term_program_test_guard();
let vars = [
"WT_SESSION",
"ConEmuPID",
"TERM_PROGRAM",
"WEZTERM_EXECUTABLE",
"WEZTERM_PANE",
"ALACRITTY_WINDOW_ID",
"ANSICON",
"TERM",
"SSH_CLIENT",
"SSH_TTY",
"NO_ANIMATIONS",
"PTYXIS_VERSION",
];
let prev: Vec<_> = vars
.iter()
.map(|name| (*name, std::env::var_os(name)))
.collect();
// SAFETY: serialised by the guard.
unsafe {
for name in vars {
std::env::remove_var(name);
}
}
let mut settings = Settings::default();
assert!(!settings.low_motion, "default is animated");
assert_eq!(settings.synchronized_output, "auto");
settings.apply_env_overrides();
assert!(settings.low_motion);
assert!(!settings.fancy_animations);
assert_eq!(settings.synchronized_output, "off");
// SAFETY: cleanup under the guard.
unsafe {
for (name, value) in prev {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
}
}
#[test]
fn ssh_session_forces_low_motion_on() {
let _g = term_program_test_guard();
+9 -9
View File
@@ -327,16 +327,16 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
let backend = ColorCompatBackend::new(stdout, color_depth, palette_mode);
let mut terminal = Terminal::new(backend)?;
// At this point Settings hasn't loaded yet, so we can't read the
// user's `synchronized_output` knob. Use the same env-based Ptyxis
// detection that `Settings::apply_env_overrides` uses, so the
// user's `synchronized_output` knob. Use the same env-based terminal
// quirk detection that `Settings::apply_env_overrides` uses, so the
// startup viewport reset matches what every later draw will do on
// this terminal. A user who has explicitly set
// `synchronized_output = "on"` to override Ptyxis detection will
// get sync wrap from the main draw loop onward; the one-time
// startup viewport reset stays opt-out for them, which is the safe
// default because the cost is at most brief tearing on the first
// frame.
let sync_output_at_init = !crate::settings::detected_ptyxis_terminal();
// flicker-sensitive hosts. A user who has explicitly set
// `synchronized_output = "on"` to override detection will get sync wrap
// from the main draw loop onward; the one-time startup viewport reset
// stays opt-out for them, which is the safe default because the cost is
// at most brief tearing on the first frame.
let sync_output_at_init = !crate::settings::detected_ptyxis_terminal()
&& !crate::settings::detected_legacy_windows_console_host();
reset_terminal_viewport(&mut terminal, sync_output_at_init)?;
let event_broker = EventBroker::new();