Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions cheetahclaws/ui/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)


Expand Down Expand Up @@ -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
Expand All @@ -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."""
Expand Down
2 changes: 2 additions & 0 deletions tests/test_render_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 28 additions & 1 deletion tests/test_stream_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading