diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index c0cfb830..252fdc7e 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -419,6 +419,15 @@ impl Settings { self.fancy_animations = false; } + // tmux/screen activity monitors treat purely animated redraws as + // activity. Keep multiplexer sessions calm by pinning animations. + let in_terminal_multiplexer = std::env::var_os("TMUX").is_some_and(|v| !v.is_empty()) + || std::env::var_os("STY").is_some_and(|v| !v.is_empty()); + if in_terminal_multiplexer { + self.low_motion = true; + 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 @@ -1305,9 +1314,18 @@ mod tests { fn no_animations_env_recognises_truthy_spellings_only() { let _g = no_animations_test_guard(); let prev_wt_session = std::env::var_os("WT_SESSION"); + let prev_tmux = std::env::var_os("TMUX"); + let prev_sty = std::env::var_os("STY"); // 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. + // Clear multiplexer markers for the same reason: they also force + // low_motion independently of NO_ANIMATIONS. + // SAFETY: serialised by the guard. + unsafe { + std::env::remove_var("TMUX"); + std::env::remove_var("STY"); + } #[cfg(windows)] unsafe { std::env::set_var("WT_SESSION", "test"); @@ -1337,6 +1355,14 @@ mod tests { Some(v) => std::env::set_var("WT_SESSION", v), None => std::env::remove_var("WT_SESSION"), } + match prev_tmux { + Some(v) => std::env::set_var("TMUX", v), + None => std::env::remove_var("TMUX"), + } + match prev_sty { + Some(v) => std::env::set_var("STY", v), + None => std::env::remove_var("STY"), + } } } @@ -1414,6 +1440,8 @@ mod tests { let prev_ssh_tty = std::env::var_os("SSH_TTY"); let prev_tilix_id = std::env::var_os("TILIX_ID"); let prev_terminator_uuid = std::env::var_os("TERMINATOR_UUID"); + let prev_tmux = std::env::var_os("TMUX"); + let prev_sty = std::env::var_os("STY"); // SAFETY: serialised by the guard. Clear SSH_* so a real // SSH session running the test suite doesn't make this // assertion trivially fail — the SSH path is exercised @@ -1423,6 +1451,8 @@ mod tests { std::env::remove_var("SSH_TTY"); std::env::remove_var("TILIX_ID"); std::env::remove_var("TERMINATOR_UUID"); + std::env::remove_var("TMUX"); + std::env::remove_var("STY"); } for program in ["iTerm.app", "Apple_Terminal", "WezTerm", "xterm-256color"] { // SAFETY: serialised by the guard. @@ -1454,6 +1484,12 @@ mod tests { if let Some(v) = prev_terminator_uuid { std::env::set_var("TERMINATOR_UUID", v); } + if let Some(v) = prev_tmux { + std::env::set_var("TMUX", v); + } + if let Some(v) = prev_sty { + std::env::set_var("STY", v); + } } } @@ -1670,6 +1706,60 @@ mod tests { } } + #[test] + fn terminal_multiplexer_env_forces_low_motion_on() { + let _g = term_program_test_guard(); + let vars = [ + "TMUX", + "STY", + "TERM_PROGRAM", + "SSH_CLIENT", + "SSH_TTY", + "TILIX_ID", + "TERMINATOR_UUID", + "NO_ANIMATIONS", + ]; + let prev: Vec<_> = vars + .iter() + .map(|name| (*name, std::env::var_os(name))) + .collect(); + + for (var, val) in [ + ("TMUX", "/tmp/tmux-501/default,1234,0"), + ("STY", "1234.pts-0.host"), + ] { + // SAFETY: serialised by the guard. + unsafe { + for name in vars { + std::env::remove_var(name); + } + std::env::set_var(var, val); + } + let mut settings = Settings::default(); + assert!(!settings.low_motion, "default is animated"); + assert!(settings.fancy_animations, "default shows the water strip"); + settings.apply_env_overrides(); + assert!( + settings.low_motion, + "{var}={val:?} must enable low_motion under terminal multiplexers (#1925)" + ); + assert!( + !settings.fancy_animations, + "{var}={val:?} must disable fancy_animations under terminal multiplexers (#1925)" + ); + } + + // 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), + } + } + } + } + // ──────────────────────────────────────────────────────────────────────── // synchronized_output / Ptyxis flicker detection // ──────────────────────────────────────────────────────────────────────── diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 958e965d..1d2b095f 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -60,17 +60,15 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // Animate the spacer between the left status line and the right-hand // chips whenever a turn is live: model loading/streaming, compacting, or - // sub-agents in flight. The spout strip is gated on `fancy_animations` - // (the "do I want a whale at all" knob); `low_motion` now governs only - // streaming pacing (typewriter vs upstream), not the spout. Dot-pulse - // counter ticks every 400 ms so `working` → `working...` reads at a - // calm pace regardless of motion mode. + // sub-agents in flight. The spout strip and dot-pulse fallback are gated + // on `fancy_animations` (the "do I want animated chrome" knob); + // `low_motion` governs streaming pacing and redraw cadence. if footer_working_strip_active(app) { let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); - let dot_frame = now_ms / 400; + let dot_frame = footer_working_label_frame(now_ms, app.fancy_animations); // Surface one compact live status row in the footer whenever a turn // is live. Tool turns get the current action plus active/done counts; // non-tool work falls back to the existing dot-pulse label. @@ -83,9 +81,8 @@ pub(crate) fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // math in `footer_working_strip_glyph_at` was tuned for this cadence // (`t = frame / 1000.0`, primary term × 8.0 ≈ 1.3 Hz at 1 ms ticks), // so frame must advance at ~1000 units/sec to produce the intended - // animation feel. `fancy_animations = false` hides the strip - // entirely; the textual `working...` pulse still keeps a heartbeat - // regardless. + // animation feel. `fancy_animations = false` hides the strip and pins + // the textual fallback to `working`. if app.fancy_animations { props.working_strip_frame = Some(now_ms); } @@ -114,6 +111,23 @@ pub(crate) fn footer_working_strip_active(app: &App) -> bool { app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress } +pub(crate) fn footer_working_label_frame(now_ms: u64, fancy_animations: bool) -> u64 { + if fancy_animations { now_ms / 400 } else { 0 } +} + +#[cfg(test)] +mod tests { + use super::footer_working_label_frame; + + #[test] + fn footer_working_label_frame_is_static_without_fancy_animations() { + assert_eq!(footer_working_label_frame(0, false), 0); + assert_eq!(footer_working_label_frame(399, false), 0); + assert_eq!(footer_working_label_frame(1_600, false), 0); + assert_eq!(footer_working_label_frame(1_600, true), 4); + } +} + pub(crate) fn is_noisy_subagent_progress(status: &str) -> bool { let status = status.trim().to_ascii_lowercase(); status.contains("requesting model response")