Skip to content
Open
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
11 changes: 11 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion hermes_cli/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions tests/cli/test_cli_approval_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This waits only for _approval_state, but the callback assigns that state before it calls _cprint. Please wait (with the same bounded deadline) for cprint.call_args_list to become non-empty before reading it; otherwise a scheduler switch between those operations makes this test flaky.

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)
Expand Down