fix(display): suppress KawaiiSpinner animation under patch_stdout (#2994)

When the CLI is active, sys.stdout is prompt_toolkit's StdoutProxy which
queues writes and injects newlines around each flush(). This causes every
\r spinner frame to land on its own line instead of overwriting the
previous one, producing visible flickering where the spinner and status
bar repeatedly swap positions.

The CLI already renders spinner state via a dedicated TUI widget
(_spinner_text / get_spinner_text), so KawaiiSpinner's \r-based loop is
redundant under StdoutProxy. Detect the proxy and suppress the animation
entirely — the thread still runs to preserve start()/stop() semantics.

Also removes the 0.4s flush rate-limit workaround that was papering over
the same issue, and cleans up the unused _last_flush_time attribute.

Salvaged from PR #2908 by Mibayy (fixed _raw -> raw detection, dropped
unrelated bundled changes).
This commit is contained in:
Teknium
2026-03-25 10:46:54 -07:00
committed by GitHub
parent 20cc1731f4
commit 114e636b7d

View File

@@ -239,7 +239,6 @@ class KawaiiSpinner:
self.frame_idx = 0
self.start_time = None
self.last_line_len = 0
self._last_flush_time = 0.0 # Rate-limit flushes for patch_stdout compat
# Capture stdout NOW, before any redirect_stdout(devnull) from
# child agents can replace sys.stdout with a black hole.
self._out = sys.stdout
@@ -253,6 +252,22 @@ class KawaiiSpinner:
except (ValueError, OSError):
pass
def _is_patch_stdout_proxy(self) -> bool:
"""Return True when stdout is prompt_toolkit's StdoutProxy.
patch_stdout wraps sys.stdout in a StdoutProxy that queues writes and
injects newlines around each flush(). The \\r overwrite never lands on
the correct line — each spinner frame ends up on its own line.
The CLI already drives a TUI widget (_spinner_text) for spinner display,
so KawaiiSpinner's \\r-based animation is redundant under StdoutProxy.
"""
out = self._out
# StdoutProxy has a 'raw' attribute (bool) that plain file objects lack.
if hasattr(out, 'raw') and type(out).__name__ == 'StdoutProxy':
return True
return False
def _animate(self):
# When stdout is not a real terminal (e.g. Docker, systemd, pipe),
# skip the animation entirely — it creates massive log bloat.
@@ -263,6 +278,16 @@ class KawaiiSpinner:
time.sleep(0.5)
return
# When running inside prompt_toolkit's patch_stdout context the CLI
# renders spinner state via a dedicated TUI widget (_spinner_text).
# Driving a \r-based animation here too causes visual overdraw: the
# StdoutProxy injects newlines around each flush, so every frame lands
# on a new line and overwrites the status bar.
if self._is_patch_stdout_proxy():
while self.running:
time.sleep(0.1)
return
# Cache skin wings at start (avoid per-frame imports)
skin = _get_skin()
wings = skin.get_spinner_wings() if skin else []
@@ -279,18 +304,7 @@ class KawaiiSpinner:
else:
line = f" {frame} {self.message} ({elapsed:.1f}s)"
pad = max(self.last_line_len - len(line), 0)
# Rate-limit flush() calls to avoid spinner spam under
# prompt_toolkit's patch_stdout. Each flush() pushes a queue
# item that may trigger a separate run_in_terminal() call; if
# items are processed one-at-a-time the \r overwrite is lost
# and every frame appears on its own line. By flushing at
# most every 0.4s we guarantee multiple \r-frames are batched
# into a single write, so the terminal collapses them correctly.
now = time.time()
should_flush = (now - self._last_flush_time) >= 0.4
self._write(f"\r{line}{' ' * pad}", end='', flush=should_flush)
if should_flush:
self._last_flush_time = now
self._write(f"\r{line}{' ' * pad}", end='', flush=True)
self.last_line_len = len(line)
self.frame_idx += 1
time.sleep(0.12)