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:
@@ -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
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user