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
33 changes: 33 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,9 @@ def load_cli_config() -> Dict[str, Any]:
"busy_input_mode": "interrupt",
"persistent_output": True,
"persistent_output_max_lines": 200,
# Print a one-line summary of resolved modal prompts (approval /
# clarify) into scrollback so the decision survives the repaint.
"persist_prompts": True,

"skin": "default",
},
Expand Down Expand Up @@ -9361,6 +9364,25 @@ def _show_voice_status(self):
for line in reqs["details"].split("\n"):
_cprint(f" {line}")

def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None:
"""Print a one-line scrollback summary of a resolved modal prompt.

Modal panels (approval / clarify) live in the prompt_toolkit layout and
vanish on the next repaint, so the question and the decision leave no
trace in the terminal scrollback. When display.persist_prompts is on
(default), emit a dim single line after the prompt resolves so the
decision survives in chat history.
"""
if not CLI_CONFIG.get("display", {}).get("persist_prompts", True):
return
detail = " ".join(detail.split())
if len(detail) > 120:
detail = detail[:119] + "…"
outcome = " ".join(outcome.split())
if len(outcome) > 120:
outcome = outcome[:119] + "…"
_cprint(f"\n{_DIM}{icon} {label}: {detail} → {outcome}{_RST}")

def _clarify_callback(self, question, choices):
"""
Platform callback for the clarify tool. Called from the agent thread.
Expand Down Expand Up @@ -9400,6 +9422,7 @@ def _clarify_callback(self, question, choices):
try:
result = response_queue.get(timeout=1)
self._clarify_deadline = 0
self._persist_prompt_summary("?", "Clarify", question, str(result))
return result
except queue.Empty:
remaining = self._clarify_deadline - _time.monotonic()
Expand Down Expand Up @@ -9513,6 +9536,16 @@ def _approval_callback(self, command: str, description: str,
self._approval_state = None
self._approval_deadline = 0
self._paint_now()
_outcome_labels = {
"once": "allowed once",
"session": "allowed for session",
"always": "added to allowlist",
"deny": "denied",
}
self._persist_prompt_summary(
"⚠", "Approval", command,
_outcome_labels.get(result, str(result)),
)
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1429,6 +1429,10 @@ def _ensure_hermes_home_managed(home: Path):
# behaves badly with replayed scrollback.
"persistent_output": True,
"persistent_output_max_lines": 200,
# Print a one-line summary of resolved modal prompts (approval /
# clarify) into scrollback so the question and decision survive the
# panel repaint. Set false to keep scrollback untouched.
"persist_prompts": True,
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
# File-mutation verifier footer. When true (default), the agent
# appends a one-line advisory to its final response whenever a
Expand Down
77 changes: 77 additions & 0 deletions tests/cli/test_cli_approval_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,3 +565,80 @@ def _worker():
# would hold a stale reference to a disposed CLI instance.
assert seen["approval_after"] is None
assert seen["sudo_after"] is None


class TestPersistPromptSummary:
"""display.persist_prompts — one-line scrollback record of resolved modals."""

def _resolve_approval(self, cli, answer, command="rm -rf /tmp/scratch"):
result = {}

def _run():
result["value"] = cli._approval_callback(command, "danger")

t = threading.Thread(target=_run, daemon=True)
t.start()
deadline = time.time() + 2
while cli._approval_state is None and time.time() < deadline:
time.sleep(0.01)
cli._approval_state["response_queue"].put(answer)
t.join(timeout=2)
return result["value"]

def test_approval_resolution_prints_summary_line(self):
cli = _make_cli_stub()
printed = []
with patch.object(cli_module, "_cprint", printed.append):
verdict = self._resolve_approval(cli, "session")
assert verdict == "session"
summary = "\n".join(printed)
assert "Approval" in summary
assert "rm -rf /tmp/scratch" in summary
assert "allowed for session" in summary

def test_approval_summary_truncates_long_command(self):
cli = _make_cli_stub()
printed = []
long_cmd = "sudo " + ("x" * 300)
with patch.object(cli_module, "_cprint", printed.append):
self._resolve_approval(cli, "deny", command=long_cmd)
summary = "\n".join(printed)
assert "denied" in summary
assert "…" in summary
# The raw 300-char tail must not be dumped wholesale.
assert "x" * 200 not in summary

def test_persist_prompts_false_suppresses_summary(self):
cli = _make_cli_stub()
printed = []
with patch.dict(cli_module.CLI_CONFIG.get("display", {}), {"persist_prompts": False}), \
patch.object(cli_module, "_cprint", printed.append):
verdict = self._resolve_approval(cli, "once")
assert verdict == "once"
assert not any("Approval" in p for p in printed)

def test_clarify_resolution_prints_summary_line(self):
cli = _make_cli_stub()
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = 0
printed = []
result = {}

def _run():
result["value"] = cli._clarify_callback("Pick a path?", ["A", "B"])

with patch.object(cli_module, "_cprint", printed.append):
t = threading.Thread(target=_run, daemon=True)
t.start()
deadline = time.time() + 2
while cli._clarify_state is None and time.time() < deadline:
time.sleep(0.01)
cli._clarify_state["response_queue"].put("B")
t.join(timeout=2)

assert result["value"] == "B"
summary = "\n".join(printed)
assert "Clarify" in summary
assert "Pick a path?" in summary
assert "B" in summary
Loading