From efb226b586a573da8c759e54b91d998bc81d96c8 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:12:54 +0200 Subject: [PATCH] fix(cli): preserve chat -q answer by gating exit-summary screen clear (#53009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In single-query (-q) mode, the assistant's final answer was printed and then immediately erased by _print_exit_summary() — which unconditionally called _clear_terminal_on_exit() (ESC[3J ESC[2J ESC[H]). The answer was present in the session store but invisible in the terminal. The clear is only needed for interactive TUI teardown (#38928) where prompt_toolkit chrome must be cleaned up. Add a clear_screen parameter to _print_exit_summary() (default True, preserving interactive behavior) and pass False from the single-query call site so the answer stays visible above the exit summary. Regression tests cover: - clear_screen=True (default) calls _clear_terminal_on_exit() - clear_screen=False skips the clear - Single-query -q path passes False end-to-end - Interactive path still clears (preserving #38928) --- cli.py | 36 ++++--- tests/cli/test_chat_q_exit_clear.py | 149 ++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 tests/cli/test_chat_q_exit_clear.py diff --git a/cli.py b/cli.py index 4f7f0e83114..63b0240ec08 100644 --- a/cli.py +++ b/cli.py @@ -12745,19 +12745,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): except (Exception, KeyboardInterrupt) as e: logger.debug("Could not persist active CLI session before close: %s", e) - def _print_exit_summary(self): - """Print session resume info on exit, similar to Claude Code.""" - # Clear the screen + scrollback before printing the summary so the - # live bottom chrome (status bar, input box, separator rules) and the - # rest of the session transcript don't get stranded above the exit - # summary (#38252). By this point app.run() has returned and - # prompt_toolkit has restored terminal modes, so writing raw escapes - # to stdout is safe. ESC[3J clears scrollback, ESC[2J clears the - # visible screen, ESC[H homes the cursor — so the summary prints at a - # clean top-left. Falls back to the platform clear command if stdout - # isn't a TTY-capable stream. Honors NO_COLOR/dumb terminals by - # skipping silently when there's no real console. - self._clear_terminal_on_exit() + def _print_exit_summary(self, clear_screen: bool = True): + """Print session resume info on exit, similar to Claude Code. + + Args: + clear_screen: When True (default), clear the terminal screen and + scrollback before printing the summary. This is appropriate for + interactive TUI teardown (#38252). Single-query (-q) mode should + pass False to preserve the printed answer (#53009). + """ + if clear_screen: + # Clear the screen + scrollback before printing the summary so the + # live bottom chrome (status bar, input box, separator rules) and the + # rest of the session transcript don't get stranded above the exit + # summary (#38252). By this point app.run() has returned and + # prompt_toolkit has restored terminal modes, so writing raw escapes + # to stdout is safe. ESC[3J clears scrollback, ESC[2J clears the + # visible screen, ESC[H homes the cursor — so the summary prints at a + # clean top-left. Falls back to the platform clear command if stdout + # isn't a TTY-capable stream. Honors NO_COLOR/dumb terminals by + # skipping silently when there's no real console. + self._clear_terminal_on_exit() print() msg_count = len(self.conversation_history) if msg_count > 0: @@ -16171,7 +16179,7 @@ def main( # banner, doesn't depend on the welcome banner being shown. cli._show_security_advisories() cli.chat(query, images=single_query_images or None) - cli._print_exit_summary() + cli._print_exit_summary(clear_screen=False) finally: _finalize_single_query(cli) return diff --git a/tests/cli/test_chat_q_exit_clear.py b/tests/cli/test_chat_q_exit_clear.py new file mode 100644 index 00000000000..9382123d0a0 --- /dev/null +++ b/tests/cli/test_chat_q_exit_clear.py @@ -0,0 +1,149 @@ +"""Regression tests for #53009: chat -q final response erased by exit-summary clear.""" + +from types import SimpleNamespace + +import pytest + +import cli as cli_mod + + +# ── A3.1 Test-First: verify _clear_terminal_on_exit gating ────────────────── + +def test_print_exit_summary_clears_screen_by_default(monkeypatch): + """Default behavior: _print_exit_summary() calls _clear_terminal_on_exit().""" + calls = [] + + class FakeCLI: + conversation_history = [] + session_start = None + + def _clear_terminal_on_exit(self): + calls.append("clear") + + monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace( + now=lambda: SimpleNamespace( + __sub__=lambda self, other: SimpleNamespace( + total_seconds=lambda: 0 + ) + ) + )) + + fake = FakeCLI() + cli_mod.HermesCLI._print_exit_summary(fake) # default clear_screen=True + + assert "clear" in calls, "_clear_terminal_on_exit should be called by default" + + +def test_print_exit_summary_skips_clear_when_clear_screen_false(monkeypatch): + """With clear_screen=False, _print_exit_summary() does NOT clear.""" + calls = [] + + class FakeCLI: + conversation_history = [] + session_start = None + + def _clear_terminal_on_exit(self): + calls.append("clear") + + monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace( + now=lambda: SimpleNamespace( + __sub__=lambda self, other: SimpleNamespace( + total_seconds=lambda: 0 + ) + ) + )) + + fake = FakeCLI() + cli_mod.HermesCLI._print_exit_summary(fake, clear_screen=False) + + assert "clear" not in calls, ( + "_clear_terminal_on_exit should NOT be called when clear_screen=False" + ) + + +# ── Production-path test: single-query -q path skips the clear ────────────── + +def test_single_query_main_skips_clear_on_exit_summary(monkeypatch): + """The single-query (-q) path calls _print_exit_summary without clearing.""" + calls = [] + clear_calls = [] + + class FakeCLI: + def __init__(self, **_kwargs): + self.console = SimpleNamespace(print=lambda *_a, **_kw: calls.append("query-label")) + self.session_id = "sq-test" + self.agent = SimpleNamespace( + session_id="sq-test", + platform="cli", + ) + + def _claim_active_session(self, surface, *, stderr=False): + calls.append(("claim", surface, stderr)) + return True + + def _show_security_advisories(self): + calls.append("advisories") + + def chat(self, query, images=None): + calls.append(("chat", query, images)) + return "done" + + def _print_exit_summary(self, clear_screen=True): + calls.append(("summary", clear_screen)) + if clear_screen: + clear_calls.append("CLEARED") # should NOT happen + + monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI) + monkeypatch.setattr(cli_mod.atexit, "register", lambda *_a, **_kw: None) + monkeypatch.setattr( + cli_mod, + "_finalize_single_query", + lambda fake_cli: calls.append(("finalize", fake_cli.session_id)), + ) + + cli_mod.main(query="hello", quiet=False, toolsets="terminal") + + assert calls == [ + ("claim", "cli", False), + "query-label", + "advisories", + ("chat", "hello", None), + ("summary", False), # <-- clear_screen=False for single-query + ("finalize", "sq-test"), + ] + assert len(clear_calls) == 0, ( + "_clear_terminal_on_exit must NOT be called in single-query mode" + ) + + +# ── Verify interactive mode still clears ──────────────────────────────────── + +def test_print_exit_summary_still_clears_in_interactive_path(monkeypatch): + """Interactive mode should still clear the screen (preserving #38928).""" + from datetime import datetime as real_datetime + + calls = [] + + class FakeCLI: + conversation_history = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + session_start = real_datetime(2026, 1, 1, 12, 0, 0) + session_id = "test-session" + _session_db = None + agent = None + + def _clear_terminal_on_exit(self): + calls.append("clear") + + monkeypatch.setattr(cli_mod, "datetime", SimpleNamespace( + now=lambda: real_datetime(2026, 1, 1, 12, 1, 0) # 1 min elapsed + )) + + fake = FakeCLI() + cli_mod.HermesCLI._print_exit_summary(fake) # default clear_screen=True + + assert "clear" in calls, ( + "Interactive mode should still clear the screen (regression test for #38928)" + )