From d921b5670d3ac784a255d0c741019e42f8ee449e Mon Sep 17 00:00:00 2001 From: CodeWhale Agent Date: Fri, 12 Jun 2026 13:27:29 -0700 Subject: [PATCH] fix(tui): keep Ghostty motion override live --- .../tui/src/commands/groups/config/config.rs | 41 +++++++++++ crates/tui/src/settings.rs | 72 ++++++++++++++++--- 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 972e1a09..979b9738 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -658,6 +658,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> if let Err(e) = settings.set(&key, value) { return CommandResult::error(format!("{e}")); } + settings.apply_env_overrides(); let mut action = None; match key.as_str() { @@ -835,6 +836,8 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> }, ), "composer_vim_mode" | "vim_mode" | "vim" => settings.composer_vim_mode.clone(), + "low_motion" | "motion" => settings.low_motion.to_string(), + "fancy_animations" | "fancy" | "animations" => settings.fancy_animations.to_string(), _ => value.to_string(), }; @@ -1394,6 +1397,44 @@ mod tests { ); } + #[test] + fn config_fancy_animations_obeys_ghostty_override() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-ghostty-fancy-config-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let prev_term_program = env::var_os("TERM_PROGRAM"); + // Safety: test-only environment mutation guarded by EnvGuard's lock. + unsafe { + env::set_var("TERM_PROGRAM", "Ghostty"); + } + + let mut app = create_test_app(); + assert!(!app.fancy_animations); + + let result = set_config_value(&mut app, "fancy_animations", "true", false); + + assert!(!result.is_error); + assert!( + !app.fancy_animations, + "Ghostty compatibility override must keep the water strip disabled" + ); + assert_eq!( + result.message.as_deref(), + Some("fancy_animations = false (session only, add --save to persist)") + ); + + // Safety: cleanup under EnvGuard's lock. + unsafe { + match prev_term_program { + Some(v) => env::set_var("TERM_PROGRAM", v), + None => env::remove_var("TERM_PROGRAM"), + } + } + } + #[test] fn config_model_accepts_future_deepseek_model_id() { let mut app = create_test_app(); diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 7eeb5313..d00521e5 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -456,18 +456,23 @@ impl Settings { self.low_motion = true; self.fancy_animations = false; } - // VS Code (TERM_PROGRAM=vscode, #1356), Ghostty (TERM_PROGRAM=ghostty, - // #1445), and a few VTE terminals (#1470) produce visible flicker at - // 120 FPS. Drop to the 30 FPS low-motion cap for them automatically. + // VS Code (TERM_PROGRAM=vscode, #1356), Ghostty (#1445), and a few + // VTE terminals (#1470) produce visible flicker at 120 FPS. Drop to + // the 30 FPS low-motion cap for them automatically. Ghostty may report + // either TERM_PROGRAM=Ghostty/ghostty or TERM=xterm-ghostty. // Like NO_ANIMATIONS above, this unconditionally overrides any // disk-loaded value — consistent precedence: env signals always win. + let term_program = std::env::var("TERM_PROGRAM") + .unwrap_or_default() + .to_ascii_lowercase(); + let term = std::env::var("TERM") + .unwrap_or_default() + .to_ascii_lowercase(); + let term_forces_low_motion = + matches!(term_program.as_str(), "vscode" | "ghostty") || term.contains("ghostty"); let vte_env_forces_low_motion = std::env::var_os("TILIX_ID").is_some_and(|v| !v.is_empty()) || std::env::var_os("TERMINATOR_UUID").is_some_and(|v| !v.is_empty()); - if matches!( - std::env::var("TERM_PROGRAM").as_deref(), - Ok("vscode") | Ok("ghostty") - ) || vte_env_forces_low_motion - { + if term_forces_low_motion || vte_env_forces_low_motion { self.low_motion = true; self.fancy_animations = false; } @@ -1673,6 +1678,7 @@ mod tests { let prev_tmux = std::env::var_os("TMUX"); let prev_sty = std::env::var_os("STY"); let prev_term_program = std::env::var_os("TERM_PROGRAM"); + let prev_term = std::env::var_os("TERM"); let prev_ssh_client = std::env::var_os("SSH_CLIENT"); let prev_ssh_tty = std::env::var_os("SSH_TTY"); let prev_tilix_id = std::env::var_os("TILIX_ID"); @@ -1690,6 +1696,7 @@ mod tests { std::env::remove_var("TMUX"); std::env::remove_var("STY"); std::env::remove_var("TERM_PROGRAM"); + std::env::remove_var("TERM"); std::env::remove_var("SSH_CLIENT"); std::env::remove_var("SSH_TTY"); std::env::remove_var("TILIX_ID"); @@ -1736,6 +1743,10 @@ mod tests { Some(v) => std::env::set_var("TERM_PROGRAM", v), None => std::env::remove_var("TERM_PROGRAM"), } + match prev_term { + Some(v) => std::env::set_var("TERM", v), + None => std::env::remove_var("TERM"), + } match prev_ssh_client { Some(v) => std::env::set_var("SSH_CLIENT", v), None => std::env::remove_var("SSH_CLIENT"), @@ -1799,18 +1810,18 @@ mod tests { let prev = std::env::var_os("TERM_PROGRAM"); // SAFETY: serialised by the guard. unsafe { - std::env::set_var("TERM_PROGRAM", "ghostty"); + std::env::set_var("TERM_PROGRAM", "Ghostty"); } let mut settings = Settings::default(); assert!(!settings.low_motion, "default is animated"); settings.apply_env_overrides(); assert!( settings.low_motion, - "TERM_PROGRAM=ghostty must enable low_motion to prevent flickering (#1445)" + "TERM_PROGRAM=Ghostty must enable low_motion to prevent flickering (#1445)" ); assert!( !settings.fancy_animations, - "TERM_PROGRAM=ghostty must disable fancy_animations" + "TERM_PROGRAM=Ghostty must disable fancy_animations" ); // SAFETY: cleanup under the guard. unsafe { @@ -1821,10 +1832,44 @@ mod tests { } } + #[test] + fn ghostty_term_fallback_forces_low_motion_on() { + let _g = term_program_test_guard(); + let prev_program = std::env::var_os("TERM_PROGRAM"); + let prev_term = std::env::var_os("TERM"); + // SAFETY: serialised by the guard. + unsafe { + std::env::remove_var("TERM_PROGRAM"); + std::env::set_var("TERM", "xterm-ghostty"); + } + let mut settings = Settings::default(); + settings.apply_env_overrides(); + assert!( + settings.low_motion, + "TERM=xterm-ghostty must enable low_motion when TERM_PROGRAM is absent" + ); + assert!( + !settings.fancy_animations, + "TERM=xterm-ghostty must disable fancy_animations" + ); + // SAFETY: cleanup under the guard. + unsafe { + match prev_program { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + match prev_term { + Some(v) => std::env::set_var("TERM", v), + None => std::env::remove_var("TERM"), + } + } + } + #[test] fn non_vscode_term_program_does_not_force_low_motion() { let _g = term_program_test_guard(); let prev = std::env::var_os("TERM_PROGRAM"); + let prev_term = std::env::var_os("TERM"); let prev_ssh_client = std::env::var_os("SSH_CLIENT"); let prev_ssh_tty = std::env::var_os("SSH_TTY"); let prev_tilix_id = std::env::var_os("TILIX_ID"); @@ -1838,6 +1883,7 @@ mod tests { unsafe { std::env::remove_var("SSH_CLIENT"); std::env::remove_var("SSH_TTY"); + std::env::remove_var("TERM"); std::env::remove_var("TILIX_ID"); std::env::remove_var("TERMINATOR_UUID"); std::env::remove_var("TMUX"); @@ -1861,6 +1907,10 @@ mod tests { Some(v) => std::env::set_var("TERM_PROGRAM", v), None => std::env::remove_var("TERM_PROGRAM"), } + match prev_term { + Some(v) => std::env::set_var("TERM", v), + None => std::env::remove_var("TERM"), + } if let Some(v) = prev_ssh_client { std::env::set_var("SSH_CLIENT", v); }