From 3d3a219ec48d299d7fa25fb026176cd00160eff1 Mon Sep 17 00:00:00 2001 From: chauncygu Date: Mon, 20 Jul 2026 13:51:16 -0700 Subject: [PATCH] Fix duplicated streaming frames on terminals without in-place redraw auto_stream_mode fell back to 'live' (in-place cursor-up redraw) for any unrecognized terminal; where that erase does not take effect (tmux/screen, plain xterm, unknown PTYs) every frame appended instead of overwriting, reprinting the response's first line hundreds of times. - Allowlist the risky 'live' tier: only positively-recognized capable emulators get it; everything else fails safe to append-only 'commit' (issues no cursor movement, so it can never duplicate frames). - Force 'commit' under tmux/screen even on a capable outer terminal. - Default module _STREAM_MODE to 'commit' so entry points that never call set_stream_mode stay safe too. 'live' remains available via config. - Update/extend tests; full suite green (2574 passed). --- cheetahclaws/ui/render.py | 35 +++++++++++++++++++++++++--------- tests/test_render_streaming.py | 2 ++ tests/test_stream_modes.py | 29 +++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/cheetahclaws/ui/render.py b/cheetahclaws/ui/render.py index 6acfe7df..efdbbbce 100644 --- a/cheetahclaws/ui/render.py +++ b/cheetahclaws/ui/render.py @@ -154,7 +154,7 @@ def _has_diff(text: str) -> bool: _accumulated_text: list[str] = [] # buffer text during streaming _current_live = None # active Rich Live instance (one at a time) -_RICH_LIVE = True # True only in "live" mode (in-place redraw) +_RICH_LIVE = False # True only in "live" mode (in-place redraw); matches the commit default below _plain_streaming_response = False # current response has fallen back from Live _live_shows_full = False # True when the live frame holds the whole response (not a tail window) @@ -171,7 +171,11 @@ def _has_diff(text: str) -> bool: # pipes / CJK-wide text alike, while still showing rich Markdown # block by block. The universal default for non-"live" terminals. # "plain" — raw token stream (only when Rich is unavailable). -_STREAM_MODE = "live" if _RICH else "plain" +# Default to the append-only 'commit' tier (never duplicates frames on any +# terminal). cli.py upgrades to 'live' at startup via auto_stream_mode() only on +# terminals known to support in-place redraw; entry points that never call +# set_stream_mode (web, --print, bridges) thus also stay on the safe tier. +_STREAM_MODE = "commit" if _RICH else "plain" _commit_idx = 0 # chars of the response already committed (rendered + printed) @@ -232,9 +236,17 @@ def auto_stream_mode(config: dict | None = None) -> str: term = _os.environ.get("TERM", "") or "" term_program = _os.environ.get("TERM_PROGRAM", "") or "" - in_ssh = bool(_os.environ.get("SSH_CLIENT") or _os.environ.get("SSH_TTY")) is_apple_terminal = (_plat.system() == "Darwin" and term_program in ("Apple_Terminal", "")) + # tmux / screen rewrite cursor-movement sequences and routinely break + # in-place redraw even under a capable outer emulator, so the 'live' + # cursor-up rewrite leaves duplicate frames. Force the safe tier. + in_multiplexer = ( + bool(_os.environ.get("TMUX")) + or term.startswith("screen") + or term.startswith("tmux") + ) + # Emulators positively known to handle in-place cursor-up redraw reliably. modern = ( term_program in _GOOD_TERM_PROGRAMS or "kitty" in term @@ -245,13 +257,18 @@ def auto_stream_mode(config: dict | None = None) -> str: or bool(_os.environ.get("WEZTERM_PANE")) ) - # Apple Terminal has a real cursor-erase bug → never full Live. - if is_apple_terminal: - return "commit" - # Untrusted network terminal → safe rich commit instead of risky redraw. - if in_ssh and not modern: + # Allowlist the risky mode: only a positively-identified capable emulator + # gets 'live'. Everything else — Apple Terminal, tmux/screen, plain xterm, + # an unknown TERM, an untrusted SSH PTY — gets 'commit', which issues NO + # cursor movement and so can never duplicate frames on any terminal. This + # makes an unrecognized terminal fail safe (append-only rich Markdown) + # instead of failing loud (hundreds of reprinted frames). 'live' is still + # available on any terminal via explicit `stream_mode=live` config. + if is_apple_terminal or in_multiplexer: return "commit" - return "live" + if modern: + return "live" + return "commit" def _make_renderable(text: str): """Return a Rich renderable: Markdown if text contains markup, else plain.""" diff --git a/tests/test_render_streaming.py b/tests/test_render_streaming.py index 68483718..3d4b1cc6 100644 --- a/tests/test_render_streaming.py +++ b/tests/test_render_streaming.py @@ -48,6 +48,7 @@ def fake_live(*args, **kwargs): monkeypatch.setattr(render, "_RICH", True) monkeypatch.setattr(render, "_RICH_LIVE", True) + monkeypatch.setattr(render, "_STREAM_MODE", "live") monkeypatch.setattr(render, "console", fake_console) monkeypatch.setattr(render, "Live", fake_live) monkeypatch.setattr(render, "_make_renderable", lambda text: text) @@ -226,6 +227,7 @@ def test_real_tail_window_end_to_end_commits_full_output(monkeypatch): monkeypatch.setattr(render, "console", con) monkeypatch.setattr(render, "_RICH", True) monkeypatch.setattr(render, "_RICH_LIVE", True) + monkeypatch.setattr(render, "_STREAM_MODE", "live") monkeypatch.setattr(render, "_current_live", None) monkeypatch.setattr(render, "_plain_streaming_response", False) monkeypatch.setattr(render, "_live_shows_full", False) diff --git a/tests/test_stream_modes.py b/tests/test_stream_modes.py index 6bef43d9..44a53077 100644 --- a/tests/test_stream_modes.py +++ b/tests/test_stream_modes.py @@ -56,10 +56,37 @@ def test_no_rich_is_plain(clean_env): assert render.auto_stream_mode({}) == "plain" -def test_local_tty_gets_live(clean_env): +def test_unknown_local_tty_fails_safe_to_commit(clean_env): + # A local TTY that is not a positively-recognized capable emulator (no + # TERM_PROGRAM, no modern marker) must fail SAFE to append-only 'commit' + # rather than risk the duplicate-frame 'live' redraw. 'live' stays reachable + # via explicit config or a known-good emulator. + assert render.auto_stream_mode({}) == "commit" + + +def test_recognized_local_emulator_gets_live(clean_env): + clean_env.setenv("TERM_PROGRAM", "WezTerm") assert render.auto_stream_mode({}) == "live" +def test_tmux_env_forces_commit_even_under_capable_emulator(clean_env): + # tmux rewrites cursor sequences → in-place redraw duplicates frames. + clean_env.setenv("TERM_PROGRAM", "iTerm.app") # capable outer terminal + clean_env.setenv("TMUX", "/tmp/tmux-1000/default,1234,0") + assert render.auto_stream_mode({}) == "commit" + + +def test_screen_term_gets_commit(clean_env): + clean_env.setenv("TERM", "screen-256color") + assert render.auto_stream_mode({}) == "commit" + + +def test_explicit_live_config_overrides_multiplexer_guard(clean_env): + # A user who knows their setup redraws fine can still force 'live'. + clean_env.setenv("TMUX", "/tmp/tmux-1000/default,1234,0") + assert render.auto_stream_mode({"stream_mode": "live"}) == "live" + + def test_dumb_terminal_gets_commit(clean_env): clean_env.setattr(render, "console", _Console(is_dumb_terminal=True)) assert render.auto_stream_mode({}) == "commit"