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
36 changes: 22 additions & 14 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12745,19 +12745,27 @@ def _persist_active_session_before_close(self):
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:
Expand Down Expand Up @@ -16169,7 +16177,7 @@ def _signal_handler_q(signum, frame):
# 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
Expand Down
149 changes: 149 additions & 0 deletions tests/cli/test_chat_q_exit_clear.py
Original file line number Diff line number Diff line change
@@ -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)"
)
2 changes: 1 addition & 1 deletion tests/cli/test_single_query_session_finalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def chat(self, query, images=None):
calls.append(("chat", query, images))
return "done"

def _print_exit_summary(self):
def _print_exit_summary(self, clear_screen=True):
calls.append("summary")

monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI)
Expand Down
Loading