From 3904bfe7b01efda33ab2500bc9b0d61bc5b3d32f Mon Sep 17 00:00:00 2001 From: Michael Bohl Date: Tue, 14 Jul 2026 15:00:57 +0200 Subject: [PATCH] fix(cli): keep approval prompt visible in scrollback Preserve a plaintext pending-approval notice when prompt_toolkit repaints race with background-thread approvals. Assisted-by: Hermes:gpt-5.6-sol --- cli.py | 11 +++++++++++ hermes_cli/callbacks.py | 12 +++++++++++- tests/cli/test_cli_approval_ui.py | 27 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 17d2bdd0e4e32..d9d6f8ab7bd22 100644 --- a/cli.py +++ b/cli.py @@ -11765,6 +11765,17 @@ def _approval_callback(self, command: str, description: str, } self._approval_deadline = _time.monotonic() + timeout + # Keep a durable notice in normal terminal scrollback. The modal + # remains the interactive control, but prompt_toolkit repaints can + # be lost when output and a background-thread approval race. + _pending_command = " ".join(command.split()) + if len(_pending_command) > 160: + _pending_command = _pending_command[:157] + "..." + _cprint( + f"\n{_ACCENT}PENDING APPROVAL:{_RST} {_pending_command}\n" + " Select once/session/always/deny with arrow/number keys and Enter." + ) + # Modal prompt — paint immediately, bypassing the throttle/resize # guard. A throttled paint here can be silently dropped (250ms # window collision or in-flight resize), leaving the panel unseen so diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index df2c55a7bb253..0919cf924f900 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -216,7 +216,17 @@ def approval_callback(cli, command: str, description: str) -> str: } cli._approval_deadline = _time.monotonic() + timeout - if hasattr(cli, "_app") and cli._app: + pending_command = " ".join(command.split()) + if len(pending_command) > 160: + pending_command = pending_command[:157] + "..." + cprint( + f"\nPENDING APPROVAL: {pending_command}\n" + " Select once/session/always/deny with arrow/number keys and Enter." + ) + + if hasattr(cli, "_paint_now"): + cli._paint_now() + elif hasattr(cli, "_app") and cli._app: cli._app.invalidate() while True: diff --git a/tests/cli/test_cli_approval_ui.py b/tests/cli/test_cli_approval_ui.py index ebca5bd85e927..4de55ec50d1a5 100644 --- a/tests/cli/test_cli_approval_ui.py +++ b/tests/cli/test_cli_approval_ui.py @@ -99,6 +99,33 @@ def test_non_smart_non_permanent_callback_preserves_session_choice(self): "rm -rf /tmp/example", allow_permanent=False, smart_denied=False ) == ["once", "session", "deny"] + def test_approval_callback_persists_pending_prompt_in_scrollback(self): + """A pending approval remains visible even if the modal repaint is lost.""" + cli = _make_cli_stub() + result = {} + + def _run_callback(): + result["value"] = cli._approval_callback("tar -xf archive.tar", "tirith warning") + + with patch.object(cli_module, "_cprint") as cprint: + thread = threading.Thread(target=_run_callback, daemon=True) + thread.start() + + deadline = time.time() + 2 + while cli._approval_state is None and time.time() < deadline: + time.sleep(0.01) + + assert cli._approval_state is not None + rendered = "\n".join(str(call.args[0]) for call in cprint.call_args_list) + assert "PENDING APPROVAL" in rendered + assert "tar -xf archive.tar" in rendered + assert "arrow/number keys" in rendered + + cli._approval_state["response_queue"].put("deny") + thread.join(timeout=2) + + assert result["value"] == "deny" + def test_sudo_prompt_restores_existing_draft_after_response(self): cli = _make_cli_stub() cli._app.current_buffer = _FakeBuffer("draft command", cursor_position=5)