diff --git a/agent/display.py b/agent/display.py index 382ca4746..462d2a439 100644 --- a/agent/display.py +++ b/agent/display.py @@ -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)