diff --git a/CHANGELOG.md b/CHANGELOG.md index 534efece..66c7bac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 534efece..66c7bac5 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -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 diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index e0212acc..ee4874c0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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", diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index b55901d0..45cea706 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -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 { 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(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 5312c444..13f60e16 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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();