fix(tui): quiet animations under tmux

Force low-motion terminal behavior under tmux/screen and keep the fallback footer label stable so passive animations do not register as activity.
This commit is contained in:
Hunter Bown
2026-05-23 13:06:57 -05:00
committed by GitHub
parent 4143409208
commit ee47d64597
2 changed files with 113 additions and 9 deletions
+90
View File
@@ -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
// ────────────────────────────────────────────────────────────────────────
+23 -9
View File
@@ -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")